Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Libratory — Agent Context

Personal tool that converts PDF books to M4B audiobooks with chapter markers. Runs locally on Apple Silicon Macs and on Linux (CPU or CUDA) — every engine runs on both. Fully offline after initial model download.

## Product Vision

This is a personal power-user tool, not a polished consumer product. The design priorities are:

**Assembly is a first-class repeatable action, not a terminal state.** The user can assemble an M4B from selected chapters at any time — mid-synthesis, after tweaking voices, after editing text, after excluding garbage chapters. "Done" just means "there's an assembled M4B file," not "nothing more can be done."

**Per-chapter control is central.** Each chapter can be independently:
- Synthesized with a different voice or speed
- Edited (custom text override before synthesis)
- Included or excluded from the final assembly
- Queued or suspended from processing
- Re-synthesized without affecting other chapters

**The user is in control of processing.** No silent retries (maxAttempts=1), no auto-decisions. Jobs fail once and stay failed. The user reviews and decides what to retry. Cancel preserves completed work.

**Offline-first.** All ML models (Kokoro TTS, Marker/Surya) are cached locally. `HF_HUB_OFFLINE=1` is set on all Python subprocesses. The app works without internet after initial model download.

**Visibility into what's happening.** Worker activity logs to both the terminal and the UI. Every subprocess event is captured. The user should never wonder "what is it doing right now?"

**UI layout mirrors the pipeline order.** The book page is a fixed-viewport shell whose numbered tabs
*are* that order: 1 Source files (input) → 2 Chapters (structure and text work) → 3 Outputs (what was
produced), with Notes trailing after them because it is not a stage. Controls live inside the stage
they affect — extraction options belong with source files, not in a generic actions area. The
selection's actions sit in a tray pinned under the chapter table, because a toolbar above a long table
scrolls away from the very selection it acts on. When adding UI, place it by asking "at which pipeline
stage does the user need this?" — and if the honest answer is "none", it belongs in the book menu (⋯)
or a modal, not wedged into a tab.

### Task Tracking

Ideas and planned features live as individual markdown files in `tasks/`. Each file captures the idea, context, and any design notes.

- To propose a new feature or idea, create a new file in `tasks/` (kebab-case, e.g., `tasks/my-feature.md`).
- **After implementing a feature, check `tasks/` for any related task files and delete them.**
- When starting work on a task, read the corresponding file first — it may contain design decisions or constraints.

### Review handoffs

When preparing a prompt for another reviewer, explicitly instruct it to write its complete report
to a named Markdown file in `docs/`, including findings, verified code locations, reproduction
steps and verification gaps (or explicitly no findings). Record the exact path in the active task
or handoff. Preserve previous review reports by choosing a new filename for a new round. When the
user says “review done”, read that recorded file directly; do not ask them to paste the report or
tell the reviewer to save it. Do not create a placeholder report that could be mistaken for a
completed review.

### Keeping libratory.dev current

The site lives in `~/repos/libratory-site` (`subev/libratory-site`, public; Vite + React) and falls
behind the code more than anything else in the project. It usually has its own agent running in
Herdr (`herdr agent list`, match on `cwd`). When a user-visible feature lands — something a reader
of the site would want to know Libratory can now do — propose a brief for that agent in your
**Cross-repo follow-ups** instead of leaving the site for later: what the feature does for a
reader, where it belongs on the page, a screenshot or recording worth using, and anything still
unreleased that must stay off the site.

## Voice — how this project speaks in public

There is no team behind this, so nothing written outward says "we". Leave the actor out where the
sentence allows it — "this was a bug in Libratory", "the pull failed before Postgres started",
"fixed in 26.911.0" — because in a bug report the subject is the code, not the person. "I" earns its
place only where someone has to own the thing: an apology, a judgement call, a mistake the code did
not make on its own. Never "we": a "we" that does not exist is a small lie told to the one person
with no way to check it.

## Architecture

pnpm monorepo with two packages:

- `packages/server` — Fastify + tRPC + Graphile Worker + Drizzle ORM (port 3034)
- `packages/web` — React 19 + Vite + Tailwind CSS v4 + react-router v8 (port 3033)

Postgres runs in Docker (`docker-compose.yml` at root), mapped to host port **5433** on loopback only (not 5432, to avoid conflicts; not `0.0.0.0`, because the password is the default one). It was briefly bundled instead and that worked; Docker won because the desktop app requires it anyway and one path beats two — `tasks/desktop-app.md` has the findings.

Environment variables are managed via `.env` at the repo root (gitignored), with `.env.example` as template. The server loads env via `dotenv` in `packages/server/src/env.ts`, validated through a Zod schema. All server code imports the typed `env` object — never reads `process.env` directly. Vars: `DATABASE_URL`, `DATA_DIR`, `PORT`, `WEB_PORT` (Vite's port; under `pnpm dev` the API port redirects a browser there instead of serving a stale `web/dist`), `CONDA_ENV_PATH` (Python env bin dir; default `<repo>/.venv/bin`, created by `scripts/setup.sh`), `POCKET_ENV_PATH` (Pocket TTS Python env bin dir; default `<repo>/.venv-pocket/bin`), `PIPER_ENV_PATH` / `BGTTS_ENV_PATH` (the two CPU Bulgarian engines' env bin dirs; default `<repo>/.venv-piper/bin` and `<repo>/.venv-bgtts/bin` — BgTTS is opt-in, `pnpm run setup --bgtts`), `LOUDKIT_ENV_PATH` (loudkit's env bin dir; default `<repo>/.venv-loudkit/bin`, built on request from the voice picker), `HF_TOKEN` is read by `scripts/setup.sh` for the gated Pocket TTS cloning weights; the server only reads whether it is set, to offer the same download from the voice picker (the download subprocess inherits it from `process.env`), optional AI provider keys — `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY` — plus optional `LOCAL_LLM_URL`/`LOCAL_LLM_MODEL` (and `LOCAL_LLM_LABEL`/`LOCAL_LLM_CONTEXT_TOKENS`/`LOCAL_LLM_TOOLS`) for a *custom* OpenAI-compatible server — local Ollama and LM Studio servers are auto-discovered with no configuration at `OLLAMA_URL`/`LMSTUDIO_URL` (default localhost; the compose files set `host.docker.internal`, since a container's localhost is itself) (cached 30s, per-model tools capability and context length read from the server); each available model is pickable (curated entries pinned in `lib/llm.ts`, extra entries via `DATA_DIR/llm-models.json`, and each configured provider's own `/models` listed at runtime by `lib/cloud-models.ts` with metadata from `lib/model-catalog.ts` — a model released after this build appears without one, and the pickers show the pinned set first with the rest behind a "show all" row); both listings and catalog are cached under `DATA_DIR`, so a restart — or a provider that cannot be reached — still resolves against real context windows rather than a per-provider guess); `DEFAULT_LLM_MODEL` is the Settings choice every no-explicit-pick request resolves to, written by `setDefaultModelKey` and honoured by `defaultModelKey` while that model can run — judged by whether its provider is configured, never by whether a listing happens to name it, because a probe that fails or a provider that reports another id must not be able to reroute every job that named no model; the home page ⚙️ opens `SettingsModal` (`llmModels.status` + `secrets.list`/`secrets.set` tRPC) which shows detected local servers/models and edits every user-settable key — written to `.env` via `lib/env-file.ts` and applied to the in-memory `env` without restart, never echoed back to the client (only `configured` and the last four characters). `lib/secrets.ts` holds the one table of those keys, LLM providers and the two cloud TTS providers alike; anything missing from it is unreachable in the desktop app, which has no checkout and no file to edit. Which file gets written is `envFilePath` — `LIBRATORY_ENV_FILE` if set (the desktop app passes `config.json`'s `envFile`), else `<repoRoot>/.env`; `updateEnvFile` writes 0600 and renames into place, because in the packaged app that call is what *creates* the file. `HOST` (default `127.0.0.1`) is what the server binds — 0.0.0.0 was a dev-box default that shipped an unauthenticated library to every network the app is opened on — and `NETWORK_ACCESS` (`none` | `shelf` | `all`; unset means `all` in Docker, `none` elsewhere) is what a non-loopback address may reach once it does (`lib/network-access.ts`); `PUBLIC_ORIGIN` (optional, `https://shelf.example.org`) is the origin the world reaches a proxied server by — the pairing code and the shelf's `machine` follow it (`lib/shelf-address.ts`); CORS is gated for the same reason: `lib/cors.ts` reflects loopback origins, plus an Origin matching the request's own `Host` when that Host is an IP literal (the phone-on-the-LAN case; a rebinding page cannot produce a literal, because to make a browser send `Host: attacker.com` the page must come from attacker.com). Names that legitimately front the server — a reverse proxy, an mDNS or tailnet name — are listed in `TRUSTED_HOSTS`. The file-path columns (`books.pdf_path`/`output_path`, `book_files.pdf_path`, `chapters.audio_path`, `chapter_translations.audio_path`, `assemblies`/`documents.output_path`) store paths **relative to `DATA_DIR`** through the `dataPath` custom column type in `schema.ts` — code on either side still sees absolute paths, and a path outside `DATA_DIR` stays absolute and resolves to itself. They were absolute until 0034, which is why renaming this checkout left 2547 rows pointing at a directory that no longer existed and made the container's `/data` unreachable by construction. `MIGRATIONS_DIR` (default `<repo>/packages/server/drizzle`) is where `main.ts` applies pending migrations at boot: a packaged app has no drizzle-kit and nobody to run `pnpm db:migrate`, so without it a fresh install comes up against an empty database. `TESSDATA_PREFIX` is optional: `lib/tessdata.ts` `tessdataDir()` uses it when set (the desktop app points it at `HOME/tessdata`, which `setup.stageRuntime` fills from the bundle) and `DATA_DIR/tessdata` otherwise, staging `configs/`, `tessconfigs/`, `pdf.ttf`, `eng` and `osd` from tesseract's compiled-in directory on first use — one directory, because `tesseract … pdf` needs the configs beside the packs. Downloaded language packs land in the same directory. `VIVLIOSTYLE_DIR` (default `<repo>/vivliostyle`) is where the Vivliostyle CLI is installed when the server cannot resolve it from a `node_modules` — which is every packaged build, where `process.execPath` is the compiled binary and `require.resolve` answers `Cannot find module … from '/$bunfs/root/libratory-server'`; `installCli` writes a one-dependency `package.json` there and runs the binary's own bun (`BUN_BE_BUN=1`), and `renderer.status` reports installed only once both the CLI and a browser are present.

## Type safety — read this before writing code

The compiler and the linter are the cheapest reviewers in this repo, and they are configured to be
strict on purpose. On 2026-08-28 a single day of enabling them found **eleven real bugs** that no
test covered. Every rule below is written because something broke.

**Statuses and kinds are unions, never `string`.** Drizzle's `enum` option already gives you
`"pending" | "extracting" | "synthesizing" | "done" | "failed" | "suspended"` and friends — never
widen one to `string`, and never accept a bare `string` where a union is available. A union is what
makes an unhandled case a compile error instead of a branch nobody wrote.

**Handle unions exhaustively.** In a `switch`, end with a `default` that assigns the value to
`never`, so adding a status to the schema fails the build at every place that has to care:

```ts
default: {
  const unhandled: never = status;
  throw new Error(`unhandled status ${unhandled}`);
}
```

`noFallthroughCasesInSwitch` is on. Ternary chains over a union get no such check — prefer a
`switch` when the branches matter.

**`noUncheckedIndexedAccess` is on: `arr[0]` is `T | undefined`.** Do not reach for `!` to silence
it — that is the flag switched off one site at a time. Use a guard, a default, or hoist the element
into a checked local. Two whole days of this repo's index accesses were audited with **zero** `!`
added, and four pre-existing ones were removed as standing in for real checks. What it caught:
a chapter dereferenced by an index held in state across list changes (99 use sites, a white page
every time), two drag handlers splicing `undefined` back into a list, an unchecked index returned
by an LLM, and an embedding loop that could spin forever writing nothing.

**An empty array is truthy.** `if (!line.xs)` does not mean "has entries". That guard let `xs[-1]`
through and drew a read-along highlight out of `NaN`s.

**Effect dependency arrays are linted and the rules are not decoration.** `exhaustive-deps`,
`set-state-in-effect` and `exhaustive-effect-dependencies` exist here because a dependency array
containing an inline arrow produced a render loop, React error #185 and a white page — and reached
a release. If a rule genuinely does not apply, disable it at that line **with a one-line reason**;
never blanket-disable, and never silence one to make a number go down.

**`unknown` over `any`, type guards over assertions.** `useUnknownInCatchVariables` is on via
`strict`, so narrow errors with `err instanceof Error` rather than casting.

**Three checks must be green before every commit** — CI runs all of them, lint first:

```bash
pnpm lint        # oxlint, under a second for 45k lines
pnpm typecheck   # tsc --noEmit across every package
pnpm test        # 538 unit + integration tests
```

Open work and the reasoning behind the current settings live in
[`tasks/type-safety-and-lint.md`](tasks/type-safety-and-lint.md).

## Colour and reading surfaces — read this before touching a className

### Two tiers, and the boundary is enforced

`packages/web/src/styles.css` is a **palette** and a **mapping**.

- **Palette** (`--pal-*`) is raw colour: literals only, steps rising as the colour darkens. Every
  step carries a contrast contract — 600 is the brand fill because ink on it is 4.93:1, 700 is text
  on cream because that is 5.36:1, 500 is what survives charcoal. Picking a colour is a lookup.
- **Semantic** (`--accent`, `--danger`, `--text-muted`, …) is a pure `var()` mapping into it.

**Components may only use semantic tokens.** `oxlint` fails the build on `bg-(--pal-…)`. If nothing
in the semantic layer fits, add one there — do not reach past it, and never inline a hex.

Two rules the palette exists to enforce, both learned the hard way:

- **`--*-text` is text IN a colour; `--on-*` is text sitting ON it as a fill.** They are not
  interchangeable. `bg-(--warning) text-white` measured 2.53:1.
- **A brand colour is not automatically a text colour.** The tangerine passes at 4.93:1 under ink
  and fails at 3.23:1 as text on cream. That is why `--accent` and `--accent-text` both exist.

### Reading surfaces are not UI surfaces

Chapter text, the read-along transcript and the reflowed text view are **read**, not operated, and
they follow different rules from the rest of the app. The design line is *machinery in sans, library
in serif*.

| | reading | UI |
| --- | --- | --- |
| face | `font-reading` (Source Serif 4) | sans |
| measure | `max-w-prose` (65ch) | whatever the layout needs |
| size | 17px modal / `text-lg` reader | `text-sm` / `text-xs` |
| ground | `--bg-reading` | `--bg-card` / `--bg-subtle` |

**Do not widen the measure.** Before this was fixed the panes ran ~119 characters per line at 15px;
Medium, Instapaper and Safari Reader all land between 65 and 71. A full-width reading pane is the
single most common regression here, because it looks fine in a screenshot and is miserable to read.

`--bg-reading` is a warm, slightly dimmed ground rather than paper white. Sepia measures roughly 25%
lower effective radiance, and it is the one display-mode claim with real evidence behind it. Text
lands at 8.9:1 — deliberately short of pure black on white, which is harsh over long passages.

Three warm-tinted controls sit together in the chapter toolbar and that is not an accident to be
tidied away: **Reset** is `--danger-bg` because it discards edits, and **Edit** is `--warning-bg`
because saving custom text changes what the narration reads: a correction keeps the read-along on
the PDF page as a best guess (`lib/text-alignment.ts` lines the edit up with the extracted text, and
`reader-doc.ts` reports `fit`), a rewrite drops the chapter to `mode: "text"` and the page marks go.
The old blue palette made these look unrelated; a warm one makes them look like siblings. They are
both cautionary, so leave them cautionary.

### Highlighting: read-along is not annotation

The read-along highlight uses the **accent**, not the conventional highlighter yellow, and that is
deliberate: a moving cue is a playhead, not a mark the reader made. Every immersion-reading product
tracks the voice in its own brand colour. Yellow is the convention for *user-created* highlights —
if this app ever grows those, they should be yellow and visibly distinct from the cue.

The chapter modal's text pane is two paints and no more: `--accent`/18 for the chunk in play and
`--accent`/35 for the sentence being spoken. It used `--bg-selected` for the first, which is the
*table-row* selection token — orange at 10% over `--bg-card`. On `--bg-reading`, a warm cream, warm
sat on warm and the band all but vanished. The `--cue-*` tokens are not the fix either: they are
theme-fixed for the white PDF paper below.

The overlay ladder over a PDF page (`CueOverlay`) is one hue at four strengths — linked chunk, seek
ring, active sentence, active word — painted with `mix-blend-multiply`, because pdf.js renders white
paper in **both** themes. Anything drawn on that layer must read against white; theme-varying tokens
do not belong there. The same applies to `LogDock`, which sits on `--bg-terminal` (dark in both
themes) and therefore uses the `--terminal-*` tokens rather than the theme ramp.

## Icons — read this before drawing an SVG

Every icon comes from **`packages/web/src/components/icons.tsx`**, which is the only file allowed to
import `@phosphor-icons/react`. Names are what the app calls the thing (`IconRename`), not what
Phosphor calls it (`PencilSimple`), so the set can be swapped without touching a call site.

```tsx
import { IconDelete } from "./icons.tsx";
<IconDelete className="h-4 w-4 text-(--text-muted)" />
```

**Need one that is not there?** Find it at [phosphoricons.com](https://phosphoricons.com), add one
`Name as IconThing` line to the module, and use it. That is the whole process — the module is a flat
re-export on purpose. Check `/components` first: it renders the full set with a name filter, read off
the module itself, so it cannot drift from what actually exists.

**Two gates enforce this**, because neither a type nor a normal lint rule can see it:

- `oxlint`'s `no-restricted-imports` fails on `@phosphor-icons/react` anywhere but the module.
- `scripts/check-icons.mjs` (in `pnpm lint`) fails on an inline `<svg>`, a unicode glyph, an emoji,
  or an HTML entity used as an icon — and on an export in the module that nothing renders.

Four rules the sweep of 82 hand-rolled icons established:

- **Size with Tailwind classes, never the `size` prop.** `className="h-4 w-4"` overrides Phosphor's
  `1em` default and keeps sizing lintable and consistent with every other box in the app.
- **Weight carries state, colour is free for something else.** Regular is idle, `weight="fill"` is
  active — the playing track, the selected voice. This is why Phosphor was chosen over Lucide, which
  is stroke-only and can only signal active by recolouring.
- **Never `aria-hidden` at the call site.** `IconDefaults` in `main.tsx` sets it for every icon;
  Phosphor sets none of its own. An icon-only button still needs its own `aria-label`.
- **An emoji is not an icon.** It is full-colour OS artwork that ignores the palette and redraws
  itself differently on macOS and Windows — which matters now that the app ships on both. A `✓` is
  not an icon either: it inherits the text font and lands at whatever size and baseline that font
  decides. If you need an arrow in genuine prose (`press ←`), spell it out — comment lines are
  skipped, and there is no per-line escape hatch: one existed, was documented, and was never used.

## Buttons — read this before styling a `<button>`

Every action goes through **`packages/web/src/components/Button.tsx`**.

```tsx
<Button variant="primary" | "secondary" | "danger" | "warning" | "success" | "ghost" | "icon"
        size="sm" | "md"
        soft            // tinted instead of filled
        square          // the variant="icon" box on any skin, for a coloured icon-only button
        to="/books/1"   // in-app route → react-router <Link>
        href="https://" // external or a download → plain <a>
        disabled />     // with to/href, renders a real disabled <button>
```

Four things it centralises that call sites kept getting wrong:

- **A disabled link is a button.** Pass `to`/`href` with `disabled` and it renders a disabled
  `<button>`, because a disabled anchor still navigates. This is what makes "show the action
  disabled, never hide it" cheap enough to actually follow.
- **`to` for in-app routes, `href` for everything else.** `href` renders a plain `<a>` and costs a
  full page reload — an easy thing to get wrong and not notice, since the destination still loads.
- **`variant="icon"` and `square` require `aria-label`** — an icon-only button has no text to name
  it, and the type will not compile without one. `square` exists because a `soft` icon-only button
  used to fall through to the text padding and sat visibly wrong in a row of `variant="icon"` ones.
- **Disabled styling and the focus ring are not yours to write.** `disabled:` states live in the
  component; focus comes from the base `:focus-visible` rule in `styles.css`.

`soft` is the quiet register of a variant: `danger`/`warning`/`success` become a tint rather than a
fill and `primary` becomes accent text with no fill. The type admits `soft` on those four only. Reach
for it when a control must warn rather than shout — the chapter Edit button is `warning soft` because
saving custom text changes what the narration reads, and a rewrite drops the chapter to mode `"text"`.

**Render a control when the thing it acts on exists; disable it when that thing exists but the action
cannot run.** Play acts on a chapter, and the chapter is there whether or not it has audio — so it
renders, disabled, with a title saying why. Cancel acts on a *running job*: with nothing running there
is no target, so it is not rendered at all, and its appearing is itself the signal that something
started. The question is never "would this be annoying to show" — it is "what does this button act on,
and does that exist right now".

`className` is **additive only** — `w-full`, `ml-auto`, `shrink-0`, `flex-1`. There is no
`tailwind-merge` here, so passing `px-2` against a variant that already sets padding is undefined
behaviour rather than an override. If a call site needs something the variants cannot express, add a
variant; do not fight the one you picked.

`scripts/check-buttons.mjs` (in `pnpm lint`) fails on any `<button>`, `<a>`, `<Link>` or `<NavLink>` carrying a hand-written
skin — a radius, plus padding or a fixed box, plus a fill or a border. A control that is genuinely
not an action opts out with a `button-ok` comment on one of the three lines directly above it. The gate
cannot check that you wrote a reason, so writing one is on you: `PillToggle` (a toggle),
`VoicePicker`'s trigger (a labelled form control skinned to match the input beside it), and selection
rows. Inconvenience is not a reason.

## Spacing — the ladder

Padding, margin and gap come off one ladder. Every rung is a Tailwind step; there is no semantic
`--space-*` tier, deliberately — colour needed one because `--accent` must resolve differently in dark,
and spacing has no second theme, so `p-4` says more than `p-(--space-card)` would.

```
0.5  1  1.5  2  2.5  3  4  6  7  8  12  16  20
```

| rung | px | what it is for |
| --- | --- | --- |
| `0.5`–`1.5` | 2–6 | inside a badge or pill; icon to its label |
| `2` | 8 | the default gap, and dense table rows |
| `2.5` | 10 | the x-padding of a small control (`Button size="sm"`) |
| `3`–`4` | 12–16 | inner and outer padding of a card |
| `6` | 24 | between sections |
| **`7`** | **28** | **reading gutter only.** Reading panes are `max-w-prose` and padding eats into the box, so this value *is* the measure — it was tuned to 67 characters. Do not use it elsewhere, and see "Do not widen the measure" above. |
| `8`–`20` | 32–80 | page gutters, empty states, clearing the fixed player |

The ladder was derived from what the app already used, not invented: these thirteen rungs cover 94% of
962 existing spacing utilities. `5` and `10` sit between rungs and round **down** to `4` and `8`.

`tailwindcss/no-restricted-classes` fails the build on an off-ladder step and on any arbitrary value
(`p-[13px]`). Widths and heights are not spacing and are untouched by the rule.

**`space-y-*` is not a mistake here.** It is the right tool on a plain block parent, and no element in
this app uses it on a real flex or grid container, where `gap` would belong instead. Converting the 47
block-parent uses would mean making 47 containers flex for no gain.

## Surfaces

Two pages are shells: **`components/book/BookShell.tsx`** (header, tabs, one scroll pane, tray, log
dock) and **`components/library/LibraryShell.tsx`** (header, crumb-and-add bar, filter bar, one
pane). Everything is pinned but the pane, and the pane's child owns its own scrolling and its own
tray — the tray has to sit outside the scroller it acts on.

Each publishes its own contract through its own context, from a pure module: `lib/book-layout.ts`
(three widths, six fields — the chapter table scrolls sideways instead of dropping columns) and `lib/library-layout.ts` (two widths, two fields — the book table scrolls the same way). Both are under test, and
both drive the same `lib/use-layout-state.ts`, which is the part worth sharing — it holds the
*layout* rather than the width and bails when a resize lands inside the same step, because a context
change walks straight past the children-identity bailout that protects the tables reading it. The
frames themselves are a dozen lines of flex column each and are deliberately not generalised; the
tray is, and `components/ActionTray.tsx` takes `compact` as a prop so both surfaces can use it.

The library's own rules, both of which look like oversights until you hit them: **a folder survives a
filter only while its subtree still has a matching book**, which is why `books.list` returns
`filterState` per book and `filterCounts` per folder — the predicate is `lib/book-filter.ts` on the
*server*, because a folder row has to answer for books the client never receives. And **dropping a
PDF anywhere on the library still works** even though the upload zone now lives in a modal: the pane
catches the drop, `captureDrop` (`lib/dnd.ts`) reads both halves synchronously because a
DataTransfer is dead after the first await, and `UploadZone` ingests them a frame later through its
own folder-scanning path.

`Section.tsx` and the `--step-input` / `--step-work` / `--step-output` ramp are **gone**. The stripe
was "the one place colour still encodes a sequence", and the numbered tab chips do that job now — the
sequence is the tab row, so a card carrying a coloured edge would be saying it twice. A produced-file row is `book/ResourceRow.tsx` (tile, title, subtitle, trailing, badge, actions), shared by
assemblies and documents — source files stay a table because they carry a shift-range selection. On an
assembly the tile *is* the transport: a play/pause circle over a hidden `<audio>`, with elapsed, a
scrub and the speed chip in `trailing` once it is playing. A native `<audio controls>` there brought
its own chrome and pushed the row's actions to the far edge.

Two other container roles are already confined to a single component or file and do **not** need
extracting — say so before "consolidating" them:

- **notice** (`px-3 py-2`, an inline message) lives in `DownloadNotice`, `ModelBundleNotice`, `UpdateProgress` and the Phone page's pairing card
- **panel** (`p-3`, nested inside a modal) appears only inside `SettingsModal`

## The Pipeline

```
PDF Upload → rawExtract (seconds, always) [→ bookNote (optional AI answer → notes)]
           → extract (opt-in, slow) → normalize (per chapter) → synthesize (per chapter) → assemble → M4B with chapters
```

Every upload extracts raw text with `pdftotext` in seconds (stored per file in `book_files.raw_text`); the slow Marker extraction is **opt-in** and started from the book page (`books.extractChapters`, or the Extract modal that the upload dialog lands on with `?extract=1`); the upload dialog itself asks only whether several files are one book or many. Raw-only files carry `book_files.status = "raw"` and are skipped by the extract worker until flipped to `pending`. Whole-book Ask AI (the assistant's `analyze_text` tool) and the upload-time AI prompt run against the concatenated raw text; every AI answer is auto-saved to the `notes` table.

**Synthetic books** (`books.kind !== "pdf"`, currently `"digest"`, `"api"` and `"ebook"`): books with no PDF (`pdfPath`/`filename` null, zero `book_files` rows) whose chapters are AI-generated text. An **api** book is created by an external script through the JSON API (`api-routes.ts` + `lib/api-books.ts`, docs in `docs/synthetic-books-api.md`) — the caller sends finished chapter text; `cleanText` is computed inline at insert (the text is already spoken prose), so `synthesize: true` queues straight to TTS, otherwise chapters arrive suspended. Digest chapters get the same inline normalization. Example consumer: `scripts/hn-top10.mjs` (Hacker News podcast book). An **ebook** is an imported EPUB (`POST /upload/ebook`, `lib/ebook-books.ts`): `lib/epub-import.ts` reads the package and cuts chapters where its table of contents points — the nav document's `toc` nav only (its `page-list` would make every printed page a chapter), else the EPUB 2 NCX, else one chapter per spine document — opening a part into its chapters only when they live in files of their own and the part's page is short. Printed page numbers (`pagebreak` markers and number-only paragraphs), note references and notes are stripped, a contents page printed as body text is dropped, and an EPUB whose spine documents are encrypted is refused as DRM-protected (font obfuscation alone is fine). Chapters arrive suspended with `cleanText` already set, the language comes from the text before `dc:language`, and the original is kept as `uploads/{bookId}/source.epub` for a re-import. A chapter sent with a `url` keeps it as `source {kind:"url"}`, and that address travels: the chapter table links it, the reader manifest carries it as `link`, and the synced EPUB writes it under the chapter's text, outside the media overlay. A book with no PDF still exports its read-along layer (`buildP2afLayer` bails on no pages only when the book *has* sources) — cues over text, with a `prose` block per paragraph from `buildText` — which is what lets a written book be read along with outside this app instead of arriving as audio only. A variant's synced EPUB (translation or rewrite) carries the same kind of layer from `buildVariantP2afLayer`, over the variant's own text with no pages: the reader imports an EPUB without `p2af/book.json` as a plain book with no voice, so until 2026-09-27 every translated export arrived on the phone as text only. A **digest** is created from the home page (select books → Create digest): one `digest` job sequentially summarizes each source book (chapter text preferred, raw text fallback via `lib/book-source-text.ts`), saves each summary as a note on the source book, and inserts one suspended chapter per source with a `chapters.source` back-link (`{kind:"book",bookId,title}` — snapshot; notes appended as chapters use `{kind:"note",noteId}`, future feed chapters use `{kind:"url"}`). Provenance in `books.origin`, run state in `books.digest_job` (progress "3/10", idempotent resume — already-summarized sources are skipped). Everything downstream (normalize/synthesize/translate/transform/cleanup/assemble/export) works on synthetic chapters unchanged; every PDF-assuming path (extract, redetect, retry, structure, propose, applyChapterBoundaries, append-upload) is guarded on `kind !== "pdf"` — keep it that way when adding features.

**Inserted chapters** (any chapter whose `source` jsonb is set — currently notes via `notes.toChapter`, "Add as chapter" on a note) don't derive from extraction output, so the three rebuild flows (retry, redetect, applyChapterBoundaries) must never delete them: they all go through `resetChaptersKeepingInserted` (`lib/insert-chapters.ts`), which keeps source-tagged chapters at the front (index 0..k-1) with audio state reset (their files die with the output dir) while newly detected chapters offset from k. Preserve this invariant in any new flow that bulk-deletes chapters.

### Job Flow (Graphile Worker)

0. **rawExtract** (`workers/raw-extract.ts`, always queued at upload) — `pdftotext` per file with `rawText IS NULL` (idempotent for appends), stores `rawText`/`rawWords`. Soft-fails (log only) on scanned/encrypted PDFs. Chains a **bookNote** job when the upload requested an AI prompt; marks `books.noteJob` failed if no file yielded text.

0b. **bookNote** (`workers/book-note.ts`, translate pool) — Runs the upload-time AI prompt against the whole book's raw text via the picked model, saves the answer as a note, tracks state in `books.note_job` jsonb (queued/running/done/failed, 15-min stale guard).

0c. **digest** (`workers/digest.ts`, translate pool) — Builds a digest book's chapters: sequentially summarizes each source book from `origin.sourceBookIds`, one suspended chapter + source-book note per source; state in `books.digest_job`; re-queue resumes (sources with an existing chapter are skipped).

0d. **ocrTextLayer** (`workers/ocr-text-layer.ts`, extraction pool) — Per file with no text layer of its own (`pdfHasTextLayer` is `false`), renders the pages with `pdftoppm -r 300 -png -gray` in chunks of 20 and runs ONE `tesseract <list> <base> -l <lang> pdf tsv`, which writes the searchable PDF and the per-word TSV in a single pass. Stores `searchablePdfPath` beside the original, the engine, the two confidence figures, and re-derives `rawText`/`rawWords` from the copy. `lib/ocr-tesseract.ts` and `lib/ocr-surya.ts` are the two engines behind one `OcrRunner` contract, `lib/ocr-text-layer.ts` the step, `lib/tesseract-languages.ts` the ISO-639-1 → tessdata table, `lib/tessdata.ts` the managed directory and the language packs: `packages/server/src/lib/tessdata-manifest.ts` is generated by `scripts/tessdata-manifest.mjs` from the `tessdata_best` commit pinned in `scripts/pins.json` (name, bytes and git blob SHA-1 per pack — sizes are never typed by hand), a pack downloads as one file verified against that SHA-1 and renamed into place, and tRPC `ocrLanguages` lists/downloads/removes them for `SettingsModal` and the `<OcrLanguagePackRow>` that `ExtractModal` shows when the book's language has no pack yet. A missing pack fails the OCR step by name rather than reading the page in the wrong language. **Surya** (`books.ocrEngine = "surya"`) runs `scripts/ocr_surya.py` in the Python env — detection + recognition per page, then an invisible text layer written onto a copy of the original with `pypdf` (BSD-3), one string per line in a glyphless font (`scripts/glyphless.ttf`, tesseract's `pdf.ttf`, Apache-2.0) drawn in render mode 3 and scaled to the line's box, exactly Tesseract's technique, so `pdftotext`, pdf.js and `page_geometry.py` see the words where the ink is; `/Rotate` pages are handled by writing in user space with a rotated text matrix. The script speaks JSON events on stdout (`start`, `page`, `detected`, `line`, `page-done`, `done`; boxes in PDF points of the displayed page, origin top-left) and `--page N --stream-lines` recognises one page in chunks of four lines so a screen can show lines as they land; `lib/ocr-surya.ts` parses them, needs the `extraction` model bundle, tries MPS/CUDA then CPU like marker, and reports no confidence figure — Surya's is not comparable to Tesseract's per-word one, and `return_words` was measured to hand every word its line's box, so line-level is all it can place. Above `OCR_GARBLED_FRACTION` (15% of words under Tesseract's confidence bar) `books.get` marks the file `ocrGarbled` and the Source files tab offers **Redo with Surya**, which is `books.retry` with `forgetTextLayer: true` — the searchable copies are unlinked and the OCR columns cleared before the usual full re-extract, so the extract worker reads the pages again with the new engine. Queued after every raw-only upload and append (a no-op when every file carries text, so a born-digital book never notices); a `fullExtract` upload lets the extract worker call `ensureTextLayer` inline per file so the pages are read once. **The default is decided, not asked:** with `books.ocrEngine` null a text-less file is read with Tesseract and that choice is then written to the book, so a scan uploaded with extraction on simply works; the language is `books.language` when set, else the page's script from Tesseract's OSD (`--psm 0` on a 300 dpi render of a middle page, trusted only above confidence 1 — at 150 dpi it called an English page Cyrillic) mapped through `packsForScript` in `lib/tesseract-languages.ts` to the first installed pack, and a script with no installed pack fails naming the packs to download rather than reading Cyrillic as English. Registers its abort controller under the bookId, so `books.cancel` stops it exactly as it stops marker, and a cancelled run leaves no searchable copy. Re-running is a no-op unless `force` or the copy was written by the other engine — changing `books.ocrEngine` and re-extracting reads the pages again with the new one — and a file that carries a text layer of its own is never re-read. `books.retry` and `extractChapters` refuse while an extraction is in flight (`extractRunning` in `lib/extract-registry.ts`), and every `extract` enqueue carries `jobKey: extract:<bookId>` with `jobKeyMode: "replace"`, because two runs on one book inserted every chapter twice. **The third engine is a cloud vision model** (`books.ocrEngine = "llm"`, `lib/ocr-llm.ts`, model in `books.ocrModel` or the Settings default): each page is rendered at 1600 px on the long edge (DeepSeek resamples anything larger, so more pixels only cost upload bytes), encoded WebP when `cwebp` is on the PATH and JPEG otherwise, and sent with a fixed prompt through the AI SDK's structured output (`Output.object` on a loose Zod shape — the model writes `text` for `paragraph` and `content` for `text`, and a strict enum failed half the POC book's pages). The pages become a Marker-compatible layout JSON at the top of the file's outDir (`llm-layout.json` + `_meta`, which `findMarkerJson` picks before any Marker subdirectory) plus `rawText` on the row with `\f` between pages — the model's own text, not pdftotext's — and the extract worker passes `prebuiltLayout` so `extractPdf` skips Marker and detects chapters from that JSON; every other engine removes a stale `llm-layout.json` before Marker runs. **Positions come from a local read:** on macOS Apple's Vision recogniser (`scripts/vision-words.swift`, a Swift binary `lib/ocr-vision.ts` compiles on first use in development and `desktop-build.sh` places beside the bundled tools) boxes every word, following skewed and clipped lines Tesseract's layout analysis drops whole — Bulgarian is read in Russian mode with correction off, so the letters come back and the fuzzy alignment forgives the spelling; elsewhere Tesseract runs as `tsv` (`parseTsv` in `lib/ocr-tesseract.ts`). Tesseract in the book's own language stays the text the exact-word fidelity check compares against, Vision serves both only in a language it has natively (`combineReadings`, `makeReference`); when both read a page, both placements are tried and the one that matched more words is kept (Tesseract won 7 of the POC book's 19 pages), and a Vision failure at runtime is logged once and costs the boxes, never the paid run. Matched words carry their similarity, and those under `DOUBT` (0.85) — the readers spelling a word differently, where the model's own misreads hide — are named per page in the log and kept in `llm-layout_meta.json`. `lib/word-alignment.ts` aligns the model's words to the boxes — Needleman–Wunsch over the two sequences with Levenshtein-fuzzy equality at 0.7, one model word allowed to take two OCR words when the first ends in a hyphen, words the reader missed placed between their placed neighbours, else at the start of the next placed word's line (a skipped line beginning is the common case) or after the previous one, squeezed to stay on the page — a run that hung off the right edge once made the viewer, which sizes a page to its content box, draw the page at a quarter width; OCR words the model dropped (bleed-through, furniture) ignored. The order constraint is what tells one `на` from the next. Scaled by `pdfPageSizes` (pdfinfo, sides swapped for `/Rotate` 90/270 — pdftoppm and pdfium render the rotated view) the boxes become block `polygon`s in the layout and one invisible string per word — on the middle `LAYER_BAND` of its box, since Vision's full-height boxes touch across rows on a skewed page and `page_geometry.py` then reads two rows as one line — in a searchable copy written by `scripts/pdf_text_layer.py` (`lib/pdf-text-layer.ts`), which reuses `ocr_surya.py`'s glyphless-font writer, so `page_geometry.py`, pdf.js search and the read-along see the AI's words where the ink is — per word, which is finer than Surya's per-line placement. The copy is a bonus on the paid reading: a writer failure is logged and the book goes on without in-PDF search. With no Tesseract pack for the language there is no reference, no placement and no copy, and `textLayerDone` is the one place that knows a file read that way is still done. `llm-layout_meta.json` carries the mean placed share; the per-page log line carries each page's. **The reading is kept apart from the placement:** `llm-pages.json` in the outDir holds the model's pages as read — written as pages land (`complete: false` until the end), so a run that fails or is cancelled keeps what it paid for and the next run reads only the missing pages — and `books.replaceWords` (`replaceFileWords` in `lib/ocr-text-layer.ts` over `replaceWords` in `lib/ocr-llm.ts`) places them again with the current readers and alignment — no model call — rewriting the layout and the copy and refreshing the chapters' block polygons in place by page and text (`matchBlockPolygons`), so a better placement never costs a re-read or a re-synthesis. `scripts/capture-ocr-fixture.ts` captures exactly what that chain consumes for a real book — the pages as read, page sizes, both readers' word tables — into `test/fixtures/private/<name>/` (gitignored: published books), and `ocr-llm-bg-scan.test.ts` runs the whole chain on it, blank pages of the same sizes standing in for the scans, asserting placed share, boxes inside the page, the skipped-line-start case, per-page joins and the share of sentences the reader can put on lines; it skips when the fixture is absent. A page-end continuation is joined only when a hyphen split the word (`joinContinuations`): a block glued across pages carries one page and one polygon, and every cue in its second half was drawn on the first page's box; the reader spans a sentence over two blocks itself. Whatever the engine, `ensureTextLayer` removes the file's `geometry.json` sidecar when it writes, replaces or drops a copy (`removeSourceGeometry` in `lib/page-geometry.ts`): the sidecar describes one text layer, and one built before OCR — a book opened in the reader first — has no lines and made every cue fall back to its block's polygon until it was rebuilt. Fidelity is measured per page against Tesseract in the book's language (no pack → no check, logged): word-multiset recall over the reference words in the model text's dominant script, because Surya read a page's bleed-through as English and Chinese; under `RETRY_BELOW_RECALL` (0.88) the page is asked once more with the local OCR's first and last lines as anchors — the model deterministically skipped a page's opening fragment and no prompt wording stopped it, the anchors did — and pages still short are logged by number. A parse failure is retried up to three times; any other provider error aborts the whole run, because retrying into a bill is worse than a failed extraction. `ocrConfidence` holds the mean recall and `ocrLowConfidenceFraction` the share of flagged pages; `isGarbled` stays Tesseract-only. Soft hyphens (U+00AD) are stripped: the model "joins" a line-end hyphen by writing one. Measured on the 19-page Bulgarian typescript that prompted it: 95% recall against Surya, 93% against Tesseract `bul`, 81–86 s, 2.3 cents; `scripts/llm-extract-poc.ts` re-runs that measurement on any PDF.

1. **extract** (`workers/extract.ts`) — Runs `marker_single` (Python subprocess) on the searchable copy where there is one, outputs structured JSON into a subdirectory. Flattens ALL blocks (not just kept types) with page numbers, polygon coordinates, and an `included` flag. **Chapter detection**: if enabled, first attempts LLM TOC-guided detection (`lib/toc-detect.ts`, model from `books.chapterModel`, default when null): reads the printed TOC from layout-preserving `pdftotext -layout` text (indentation = hierarchy, entries carry a `level`), asks the model which TOC tier is the chapter tier, maps printed page numbers to PDF pages from every strong title match (`lib/toc-anchor.ts`, a drift-tolerant monotonic chain keeping one anchor per entry, so a chapter heading repeated in the notes or index loses to the body offset), and places each chosen entry on the heading near its expected page, asking the model only about the garbled ones. Without a usable TOC it selects from the heading catalog (with words-after-heading). The parsed TOC is persisted on the proposal (`chapterProposal.toc`) so the crafting view can show what was found. Falls back to the numbered-chapter tier (Chapter N / Глава N sequences, ToC listing pages excluded), then the heading-level heuristic (h1 → h2 → fallback word-count split). Stores per-chapter `sourceBlocks` (jsonb) with full block metadata, plus `pageStart`/`pageEnd`. Creates chapter rows in DB. Queues normalize jobs. When the upload asked for auto-synthesis (`skipSynthesis: false` — the unattended "drop a PDF, get an audiobook" path) it also queues a `waitForAll` **assemble** here, so the promised M4B is a visible waiting job rather than something inferred later.

2. **normalize** (`workers/normalize.ts`, per chapter, parallel) — Strips markdown, reference markers, URLs, rejoins hyphenated line breaks. Saves clean text. Queues synthesize job.

3. **synthesize** (`workers/synthesize.ts`, per chapter, 2 concurrent by default — the `tts` pool in Settings) — Runs `scripts/synthesize.py` (Kokoro TTS with MPS/Metal GPU acceleration). Uses `customText ?? cleanText ?? rawText` fallback chain for input text. Two-step process: G2P + phoneme chunking upfront (for accurate progress), then synthesis loop. The Kokoro, MMS, BgTTS, Piper and Pocket scripts stream their WAV into `<output>.partial` (renamed when the chapter is whole, removed by the runner on failure) instead of holding the chapter in memory, and the two on MPS (Kokoro, MMS) call `torch.mps.empty_cache()` after each chunk — without it a 600-chunk chapter grew past 9 GB and two at once ran a 16 GB Mac out of memory. Produces WAV at 24kHz, FFmpeg converts to M4A (AAC 64k, 44.1kHz mono — pinned so assemblies can concat without re-encoding). Skips suspended chapters. Writes chunk progress to DB.

4. **assemble** (`workers/assemble.ts`, user-triggered) — FFmpeg stream-copies selected chapter M4As into one M4B with native chapter markers (ffmetadata), generated cover art, and the audiobook media-type atom; legacy MP3 chapters are re-encoded to the pinned AAC shape first. Assembly is an explicit user action: it is queued only by the user ("Assemble selected" / "Assemble when ready") or by the auto-synthesis upload path in step 1. Finishing a synthesis run never queues one on its own — a manual run usually wants a synced EPUB, not an M4B. Each assembly is recorded in the `assemblies` table with metadata (duration, chapter count, summary).

5. **translate / translateTitles** (`workers/translate.ts`, `translate-titles.ts`, translate pool) — Per-chapter LLM generation of a *variant* (model from `params.model`, default when unset) into `chapter_translations` (TS export `chapterVariants`): either a translation (kind `translation`, key = language) or a prompt-driven rewrite (kind `transform`, key = preset id like `eli5` or `custom-<slug>`; the prompt is snapshotted on the row, presets live in `lib/transform-presets.ts`). Chunked via `lib/transform.ts` (`runToken` fencing, `sourceHash` staleness detection; transforms can run whole-chapter via `params.mode`); translateTitles backfills translated chapter titles (translation lanes only — transforms keep the original title). Replacing a variant's text (a restart, a re-run from scratch, a new transform prompt) removes its narration through `lib/variant-narration.ts` — audio, sync map and chunk previews — keeping only a pending request to narrate, and the routes refuse while that text is being synthesized: the export picks chapters by audio status, so stale audio left `done` used to ship beside the new text. Chunks stream token-by-token: the worker publishes deltas (including the model's hidden reasoning as `thinking` events) through the in-process channel in `lib/translate-live.ts`, relayed to the modal by `GET /translations/:id/stream` SSE; the DB row stays the source of truth, written once per completed chunk. DeepSeek-style thinking mode is OFF by default for variants (`params.thinking`, toggled by the modal's "Reasoning" checkbox and passed to `variants.start`/`createTransform` alongside `params.model`; bulk runs inherit the lane's stored flags; ignored by providers without the toggle) — v4-flash otherwise reasons at high effort on every chunk, several times slower, and ignores `temperature` while thinking.

6. **synthesizeTranslation** (`workers/synthesize-translation.ts`, tts pool) — TTS for a finished variant (audio state on the `chapter_translations` row, per-variant-slug output dir + chunk previews + sync map). Voice/speed resolve from `books.variantVoices[key]`, falling back to the book's; the Synthesize modal on a variant tab edits the lane, not the book. Like the original lane, it never queues an assembly of its own.

7. **assembleDocument** (`workers/assemble-document.ts`, assembly pool) — Renders selected chapters to `pdf` (one HTML document) or `epub` (one HTML file per chapter plus a generated `vivliostyle.config.mjs`, so readers get one spine item per chapter and a real nav document) via Vivliostyle CLI, or builds the `epub-sync` read-along EPUB from chapter audio + sync maps (`lib/readaloud-epub.ts`), or the `epub-bilingual` EPUB (`buildBilingualExportLayer` in `lib/bilingual-export.ts`: the original-language book with one translation's pairs, links and either narration as a `p2af-bilingual/1` reading layer, plus the pages for a printed book); records a `documents` row. Original-language synced and text EPUBs also carry any current bilingual documents for their chapters (`lib/epub-reader-layer.ts`).

8. **propose / redetect** (`workers/propose.ts`, `redetect.ts`, extraction pool) — LLM chapter-boundary proposals (crafting view) and full chapter re-detection without re-running marker.

9. **sweep** (`workers/sweep.ts`, startup) — Requeues/fails stranded jobs after a server restart (stuck extracting/assembling books, stale cleanup runs).

10. **indexBook / embedChunks** (`workers/index-book.ts`, `embed-chunks.ts`, index pool) — Search-index maintenance: `indexBook` chunks every text unit (book_files raw text with `\f` page mapping, chapter effective text with per-chunk pages from marker `sourceBlocks` when present, done translations) into `book_chunks`, skipping units whose sha256 `sourceHash` is unchanged; then chains `embedChunks`, which batch-embeds `embedding IS NULL` rows through the BGE-M3 singleton (`lib/embeddings.ts`). State in `books.search_index` jsonb (queued/chunking/embedding/done/failed). FTS works as soon as chunking completes; embeddings are a second pass. Queued via `lib/search-index.ts` `queueIndexBook()` (jobKey-deduped) from every text-mutating completion: rawExtract, extract, redetect, cleanup done, translate done, customText save/reset, digest, notes.toChapter.

11. **alignBilingual / linkBilingual** (`workers/prepare-bilingual.ts`, one function behind two task names) — Two-language preparation per finished translation, stored in `bilingual_preparations`. `alignBilingual` (index pool, because it embeds with BGE-M3) splits both texts into sentences with `Intl.Segmenter` (`lib/bilingual-segment.ts`, no per-language rules), aligns them by dynamic programming over similarity allowing up to four sentences a side (`lib/bilingual-align.ts`), then judges each step afterwards as `matched` / `uncertain` / `source-only` / `target-only`. `linkBilingual` (translate pool) asks the model, in batches, which token IDs of each matched pair mean the same thing (`lib/bilingual-links.ts`); an invalid answer fails its batch, not the run, and a provider error ends it. A job captures both texts' revisions and publishes under row locks only if they still hold (`lib/bilingual-store.ts`); words bind to the pairing revision too. Completed batches survive a cancel or failure and only an explicit re-run continues — the user decides. Started from the chapter tray's *Bilingual reading* (`bilingual.prepareSelection`), per chapter from the chapter modal, or MCP `prepare_bilingual`. The wire format is `docs/bilingual-format.md`; `lib/bilingual-timing.ts` projects text offsets onto each recording with the method named per edge, and recordings never share a clock.

Workers run in seven pools, declared in `workers/pools.ts` and started by `workers/setup.ts`: `tts` (default concurrency 2 — Kokoro contends for the GPU), `raw` (2 — rawExtract, so raw text never queues behind a 30-minute marker run), `extraction` (1 — extract/redetect/propose/ocrTextLayer), `prep` (2 — normalize, milliseconds of regex that must never queue behind a marker or propose run), `assembly` (1 — assemble/assembleDocument, separate so exports never queue behind a long extraction), `translate` (3 — translate/translateTitles/cleanup/bookNote/digest/linkBilingual), `index` (1 — indexBook/embedChunks/alignBilingual; BGE-M3 contends for the GPU with TTS). Each default is overridable from Settings → "Background work", which writes `WORKER_CONCURRENCY_<POOL>` to `.env` and rebuilds that one runner (`applyPoolConcurrency`) — graphile fixes concurrency at runner construction, and its graceful shutdown kills anything still running after five seconds, so the mutation refuses, and the UI disables the control, while that pool has a locked job. `POOL_META` owns the per-pool ceiling and the caution copy; `tasks` there is what maps a locked job back to its pool. All jobs use `maxAttempts: 1` — no silent retries. Assemblies and document exports are deduplicated via graphile `jobKey` (`lib/output-readiness.ts` owns the key format, so repeat requests and the upload path's queued assembly collapse into one job), and queued/running/waiting exports are surfaced by `books.pendingDocumentExports`.

**Deferred outputs** — `assemble` and `assembleDocument` accept `waitForAll`, which the UI sets when chapters are still synthesizing ("When all N finish" in the output-timing switch). The worker's first act is `deferUntilInputsSettle` (`lib/output-readiness.ts`): if any *selected* chapter still has audio in flight (`pending`/`normalizing`/`synthesizing`, or the variant's `audioStatus` in a language view) it re-queues itself under the same jobKey with `runAt: +30s` and returns without touching `books.status`. The queue row is the only deferral state — it survives restarts via `sweep.ts` and the UI reads `run_at > now()` as "waiting". Failed and suspended chapters count as **settled**, otherwise one dead chapter would strand the request forever; `waitingSince` in the payload caps the total wait at 24h. PDF/EPUB in the original view never wait, since chapter text exists from extraction.

### Book Status

Book status during the synthesis phase is **computed from chapter statuses**, not stored. The `computeBookStatus()` function in `routes/books.ts` derives status:

- `extracting` / `assembling` — from stored `books.status` (book-level operations)
- `synthesizing` — any chapter is `pending` / `normalizing` / `synthesizing`
- `done` — all chapters done AND `outputPath` exists
- `failed` — any chapter failed
- `suspended` — all non-done chapters are suspended

### Chapter Statuses

`pending` | `normalizing` | `synthesizing` | `done` | `failed` | `suspended`

Chapters can be individually queued (creates Graphile job) or suspended (no job, won't be processed). Cancel sets non-done chapters to `suspended`, preserving completed audio.

## Key External Tools

| Tool | Purpose | Called from |
|------|---------|------------|
| **Marker** (`marker_single` CLI, `pip install marker-pdf`, pinned in `pyproject.toml` / `uv.lock`) | PDF → structured JSON | `lib/marker.ts` |
| **Kokoro TTS** (`pip install kokoro`) | Text → speech via MPS GPU | `scripts/synthesize.py`, called by `lib/kokoro.ts` |
| **Piper** (`piper-tts`, GPL-3.0, subprocess in `.venv-piper`; voice `bg-piper:dimitar` = `rhasspy/piper-voices` `bg_BG-dimitar-medium`, CC0) | Bulgarian on the CPU, ~20x realtime, speed via `length_scale`. **Word timings** from its own phoneme durations (`include_alignments`, needs the `onnx` package): espeak glues short words together, so each word is phonemized alone and aligned to the sentence's phonemes by `difflib`, then spoken words are mapped back to written ones (`scripts/phoneme_words.py`, tested through python3 in `phoneme-words.test.ts`) | `scripts/synthesize_piper_tts.py`, called by `lib/tts.ts` |
| **BgTTS-38M V2** (`beleata74/BgTTS-38M-V2`, Apache-2.0, over MioCodec + WavLM base+; `.venv-bgtts`, because MioCodec needs torchaudio ≤2.9.1 and `.venv` pins torch 2.13; MioCodec is installed `--no-deps`, its pyproject points torch at the CUDA index) | Bulgarian narrator on the CPU, ~11x realtime, voices `bg-bgtts:female\|male\|male2` from the reference clips the model ships. **Word timings** from its own cross-attention: one causal pass over the codes it produced, three heads that follow the text (fixed to the pinned checkpoint; `spikes/forced-alignment/score.py scan` picks them again), a forward-only path, word starts moved out of silence, spoken words mapped back to written ones as Piper's are (`scripts/attention_words.py`, tested on hand-drawn attention in `attention-words.test.ts`) | `scripts/synthesize_bgtts.py`, called by `lib/tts.ts` |
| **loudkit** (`loudreader/loudr-1`, Apache-2.0, derived from Resemble AI's Chatterbox (MIT); `.venv-loudkit`, because loudkit caps numpy below 2.4) | Opt-in narrator in nine languages (`loudkit:<voice>`, the 23 CC0 voices; the CC-BY LibriVox readers are left out, so no French), generator on the CPU and renderer on Metal, ~3.3x realtime with `attention="sdpa"` and 4 generator threads. **Word timings** from its generator's self-attention: the heads that read the text in step with the voice (6.9, 10.10 for loudr-1; pinned with a +50 ms shift to the revision in `MODELS`), read beside the attention call so audio is unchanged, a forward-only path per loudkit window, each window's first word at its voice onset, spoken words mapped back to written ones (`scripts/loudkit_attention.py`, tested in `loudkit-attention.test.ts`; `spikes/loudkit-alignment/score.py scan`/`fold` picks the heads again, results in `tasks/forced-alignment.md`). loudkit's own word times are letter-count estimates and are never used; a chunk whose trace fails gets none | `scripts/synthesize_loudkit.py`, called by `lib/tts.ts` |
| **macOS `say`** (system binary; ALL installed system voices exposed as `say:<name-slug>` — discovered via `say -v '?'` in `lib/say-voices.ts`, listed by tRPC `sayVoices.list`, shown as a dynamic picker group; install more in System Settings → Accessibility → Spoken Content) | Free offline TTS in any installed system voice (~25x realtime, supports speed via `-r`) | `scripts/synthesize_say_tts.py`, called by `lib/tts.ts` |
| **FFmpeg** (system binary) | WAV→M4A (AAC), M4B concat + chapters + cover | `lib/ffmpeg.ts` |
| **music-metadata** (npm) | Read M4A/WAV duration | `workers/synthesize.ts`, `lib/sync-map.ts` |
| **pdftotext** (poppler, system binary) | Fast raw text extraction at upload; `pdfHasTextLayer` is the probe the OCR step keys off | `lib/pdf-raw-text.ts` |
| **Surya OCR** (already in the env for marker; `pypdf` writes the layer) | Reads a scanned PDF into a searchable copy with line-level boxes — slower, better on photographed, curled or faded pages | `scripts/ocr_surya.py`, called by `lib/ocr-surya.ts` |
| **AI OCR** (cloud; any configured LLM provider with image input) | Reads a scanned PDF page by page into a Marker-compatible layout — joins split words, drops furniture, any language the model reads; word positions from a local Tesseract read aligned to the model's text, written into the searchable copy | `lib/ocr-llm.ts`, `lib/word-alignment.ts`, `scripts/pdf_text_layer.py`, through `lib/llm.ts` |
| **Tesseract 5** + **pdftoppm** (poppler; `brew install tesseract`, apt `tesseract-ocr tesseract-ocr-eng tesseract-ocr-osd`) | Reads a scanned PDF into a searchable copy with word-level boxes. `TESSDATA_PREFIX` is **one** directory and needs `configs/pdf` + `pdf.ttf` beside the `.traineddata`, or `tesseract … pdf` fails instantly with no useful message | `lib/ocr-tesseract.ts` |
| **LLM providers** (auto-discovered local Ollama / LM Studio; DeepSeek / OpenAI / Anthropic / Gemini via API keys) | Translation, transforms, cleanup, TOC detection, digests, Ask AI, chat | `lib/llm.ts` |
| **Cartesia API** (`CARTESIA_API_KEY`; voices `cartesia:<uuid>` from the live library via tRPC `cartesiaVoices.list`, "Cartesia" picker tab) | Neural cloud TTS (Sonic 3.6, 44.1kHz, speed 0.6–1.5x); TS-native chunked client, no Python | `lib/cartesia.ts`, dispatched by `lib/tts.ts` |
| **ElevenLabs API** (`ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL`; voices `elevenlabs:<handle>` via tRPC `elevenlabsVoices.list`) | Neural cloud TTS (24kHz pcm — 44.1k needs their Pro tier; speed 0.7–1.2x). **Metered**: the free tier is 10k characters/month, so synthesis preflights `elevenlabsVoices.quota` and refuses before spending if the chapter will not fit. Word timings come from character-level `alignment`, grouped in `charactersToWords`. A voice is listed natively under every language ElevenLabs verified it in, and — marked "Not a native … voice", sorted last, previewed in that language (`/preview/:id?lang=`) — under every other language the configured model reads (`MODELS` in `lib/elevenlabs.ts`, from the model docs: `/v1/models` needs a `models_read` permission a voice key may lack); MCP `list_voices` says `native: false` for those | `lib/elevenlabs.ts`, dispatched by `lib/tts.ts` |
| **Vivliostyle CLI** (npm, spawns a rendering browser) | PDF/EPUB document export | `lib/vivliostyle.ts` |
| **bun** (the compiled server, re-entered as its own CLI via `BUN_BE_BUN=1`) | Runs the JS the packaged app cannot resolve: `scripts/hn-top10.mjs` and the Vivliostyle CLI, plus the `bun install` that puts the latter in `VIVLIOSTYLE_DIR` | `script-run-routes.ts`, `lib/vivliostyle.ts` |
| **zip/unzip** (system binaries) | EPUB packaging for synced exports | `lib/readaloud-epub.ts` |

## Database

**Optional model bundles** (`scripts/models.py`, `lib/model-bundles.ts`, tRPC `models.list`/`models.download`): setup caches only Kokoro; Marker/Surya (5.1 GB), BGE-M3 (4.3 GB) and the MMS Bulgarian voice (290 MB) download at the one doorway each unlocks — the extract button, the chat page, the Bulgarian voice pane — via `<ModelBundleNotice>`. Status is read from Python because surya's cache is a per-OS platformdirs path; that path is computed with `platformdirs` directly rather than importing `surya.settings`, which drags in torch and made the check 6x slower. `.models-missing` at the repo root forces the absent state and bypasses the status cache.

PostgreSQL 17 in Docker. Schema in `packages/server/src/schema.ts`. Migrations in `packages/server/drizzle/`.

Connection string via `DATABASE_URL` env var (required, validated by Zod).

### Tables

**books** — id (uuid), title, kind (`pdf` | `digest` | `api` | `ebook`, default pdf), textSource + rights (what a public shelf says about a copy, typed under Book details), filename + pdfPath (nullable — null for synthetic books), outputPath, status (`pending` | `extracting` | `synthesizing` | `assembling` | `done` | `failed` | `suspended`), voice, speed, error, ocrEngine (`tesseract` | `surya` | `llm` | null — null means the pages carry their own text and no OCR step runs), ocrModel (nullable — the vision model the `llm` engine reads with, a registry key; null = Settings default), llmChapterDetection, chapterDetection, chapterProposal (jsonb), translationLanguage (last active variant key — drives the variant view default), variantVoices (jsonb `VariantVoices` — per-variant-key voice/speed overrides keyed by lane; absent fields fall back to books.voice/speed, set via `variants.setVoice`), skipSynthesis, totalChapters, structureConfirmedAt (set by `books.applyChapterBoundaries` — applying is the review — or by `books.confirmStructure` when the checked boundaries are the chapters as they stand, so nothing is re-sliced; cleared by `resetChaptersKeepingInserted`, so every rebuild asks again — the chapter tray's one orange button follows it: Review chapters, pulsing, until confirmed (a book with no PDF skips this stage — its chapters arrived as they are, and there is no structure to review); then Synthesize; then Export once selected chapters have audio and none is in flight), noteJob (jsonb), origin (jsonb `BookOrigin` — digest or api provenance), digestJob (jsonb `DigestJob`), folderId (FK folders, `set null` on folder delete — book deletion must go through `lib/delete-book.ts` for disk cleanup, so never cascade), profileId (FK profiles, defaults to the fixed default-profile id), createdAt, updatedAt

**folders** — id (uuid), name, parentId (self-FK, cascade — nested folders), profileId (FK profiles), createdAt, updatedAt. Books live in at most one folder (null = home/root). Home shows only root-level folder rows + unfiled books; `/folders/:id` shows a folder's contents. Recursive aggregates (bookCount/active/size) are computed in `books.list`; subtree/ancestor walks via CTE helpers in `lib/folders.ts`. `folders.delete` collects all descendant books first and deletes each via `deleteBook` before removing the folder row. `folders.move` reparents a folder (rejects moves into the folder's own subtree).

**chapters** — id (uuid), bookId (FK, cascade delete), index, title, rawText, cleanText, customText, audioPath, durationMs, progress (text, e.g. "12/48"), status (`pending` | `normalizing` | `synthesizing` | `done` | `failed` | `suspended`), error, selected (boolean, default true), pageStart/pageEnd (1-based), sourceBlocks (jsonb — block metadata with type, text, page, included, level?, polygon?), sourceFileIndex, source (jsonb `ChapterSource` — digest/note back-link; non-null marks an inserted chapter that survives rebuilds), synthesizedWith (jsonb `SynthesizedWith`: voice, speed and `at`, when the audio was made — audio older than `at` is dated from its file at boot by `lib/synthesized-at.ts`), cleanup (jsonb `ChapterCleanup` run state), createdAt

**book_files** — id (uuid), bookId (FK, cascade delete), index, filename, pdfPath, searchablePdfPath (the OCR'd copy written beside the original, which is never replaced), ocrEngine, ocrConfidence + ocrLowConfidenceFraction (fractions in [0,1] — Tesseract's mean word confidence and the share of words under 60, the evidence a "redo with the slower engine" offer is made from), status (`raw` | `pending` | `extracting` | `done` | `failed`), selected, skipSynthesis, rawText, rawWords, error, createdAt. One row per uploaded PDF; `raw` = pdftotext-only, marker neither queued nor planned. **Every path that reads a book's pages goes through `readablePdfPath` (`lib/pdf-raw-text.ts`)**, which prefers the searchable copy — marker, page geometry, TOC detection, raw text, the read-along EPUB, and `/pdf/:fileId` (the in-app reader). A route whose job is to hand back the original keeps `pdfPath`.

**chapter_translations** (TS export `chapterVariants`) — id (uuid), chapterId (FK, cascade delete), language (TS property `key` — the variant key: a language name for translations, a preset id or `custom-<slug>` for transforms), kind (`translation` | `transform`), label (display name; null → key), prompt (snapshot of the transform instruction; null for translations), params (jsonb `VariantParams`: temperature, chunked/whole mode), title, text, status (`pending` | `translating` | `done` | `failed` | `suspended`), progress, error, sourceHash (staleness detection), runToken (write fencing), audioPath, audioDurationMs, audioStatus, audioProgress, audioError, synthesizedWith, createdAt, updatedAt. Unique (chapterId, key). First-class per-chapter variants (translations and AI rewrites) — original text always preserved. Physical table/column names predate transforms and are kept to avoid a risky rename.

**bilingual_preparations** — variantId (uuid PK, FK `chapter_translations`, cascade), pairs (jsonb `PairArtifact` — sentence pairs with token tables and the text revisions they were made from), links (jsonb `LinkArtifact` — word links per pair, kept per completed batch), pairJob / linkJob (jsonb run state: run id, status, progress, captured revisions), updatedAt. One row per translation; a text edit makes it unavailable until it is prepared again. Producer versions (`bge-m3-dp/1`, `intl-segmenter-word/1`, `token-ids/1` in `lib/bilingual-preparation.ts`) are independent of the reading format.

**assemblies** — id (uuid), bookId (FK, cascade delete), outputPath, durationMs, chapterCount, chapterSummary, chapterIds (json array), createdAt

**documents** — id (uuid), bookId (FK, cascade delete), language (null = original), format (`pdf` | `epub` | `epub-sync` | `epub-bilingual`), outputPath, shelfHidden (kept off the phone shelf; the file stays), narration (jsonb `DocumentNarration` — a lane per narrated text with cue level, running time and voice names, written at export by `lib/document-narration.ts` and backfilled from the sync maps at startup for older rows; what the shelf listing's `narrated`/`durationMs`/`voice`/`level`/`levels` come from), chapterCount, chapterSummary, chapterIds (json array), createdAt. Written by the `assembleDocument` worker: `pdf`/`epub` render text via Vivliostyle CLI (first run downloads a rendering browser into the Vivliostyle cache); `epub-sync` is a read-along EPUB 3 with Media Overlays (audio + SMIL-highlighted text, built by `lib/readaloud-epub.ts` + system `zip`, playable in readers that support EPUB media overlays). Layout mirrors the IDPF moby-dick sample (flat OEBPS, no `../` in SMIL refs).

**Sync maps** — `ch000.sync.json` next to each chapter/translation audio file: per-chunk `{text, startMs, endMs}`, plus optional per-word timings on v2 maps (`lib/sync-map.ts`; v1 files stay readable). Kokoro reports word timings during synthesis (`chunk-NNN.words.json` beside each chunk WAV); other engines don't, and `lib/cues.ts` turns whatever exists into cues — a sentence per cue where words are known, the whole chunk where they aren't — reporting which through `granularity` — and, separately, what the print itself can carry through `marks`, which is a fact about the pages rather than the audio (`printMarks`, `docs/read-along.md`). Written by the synthesize workers after the M4A encode; backfilled on demand from chunk WAV durations (`ensureSyncMap`) during `epub-sync` export. Once the WAVs are cleaned, `chapters.get`/`variants.detail` derive chunk previews from the sync map (`syncMapChunkPreviews`) and the chapter modal plays them by seeking the chapter audio (`startMs`/`endMs`) instead of loading per-chunk files. Once the sync map exists, the chunk WAVs are disposable — the map + chapter audio can rebuild read-along exports forever, so the synthesize workers delete the chapter's chunk dir right after writing it (kept only if the map couldn't be built). `scripts/cleanup-chunk-wavs.ts` (`pnpm cleanup:chunks`, `--apply` to delete) sweeps chunk dirs of finished chapters plus orphans from older chapter splits, ensuring sync maps first.

**bookLogs** — id (uuid), bookId (FK, cascade delete), message (text), createdAt

**profiles** — id (uuid), name, createdAt. Lightweight workspaces (no auth): folders and books carry a profileId; list/create/move routes are scoped to the caller's profile via the `x-profile-id` request header, resolved in `trpc.ts` `createContext` (missing/invalid header → fixed `DEFAULT_PROFILE_ID`, which migration 0025 seeds as "Default" and backfills all pre-profile data onto). Book-level routes (`books.get`, chapters, translations, notes, file downloads) stay unscoped by design — profiles are an organizational boundary, not a security one. The default profile cannot be deleted; other profiles only when empty.

**notes** — id (uuid), bookId (FK, cascade delete, **nullable** — null marks a library-chat answer), profileId (FK profiles), prompt, model (a model key — a pinned `flash`, a discovered `deepseek:…`, a local `ollama:…`; a saved legacy `pro` resolves to the model it named), result (markdown), scope (jsonb `NoteScope` — chapter id+title snapshot, `book-raw`, or `library {folderId?, question}`; no FK to chapters so notes survive chapter re-detection), createdAt. Auto-inserted by `chapters.aiPrompt`, `books.aiPromptRaw`, the `bookNote` worker, and `notes.saveLibraryAnswer` via `lib/notes.ts` `saveNote()`. `notes.toChapter` refuses library notes (no book to attach to).

**chat_conversations** — id (uuid), profileId (FK profiles, cascade), title (empty until the first question names it — an 80-character excerpt, no model call), scope (jsonb `ChatScope`, fixed at creation; book titles and the folder name are snapshots so a removed source can still be named), citedBooks (jsonb `ChatBookRef[]` — books the answers quoted), model (last model key used), createdAt, updatedAt (last activity; the sidebar's order). No FK to books or folders on purpose: a conversation outlives what it searched.

**staged_files** — id (uuid), profileId (FK profiles, cascade), conversationId (FK chat_conversations, cascade, nullable until a thread sends it), filename, sizeBytes, sha256, path (`dataPath`), status (`uploading` | `ready` | `used` | `removed` | `expired` — the row outlives the file by a day so a chip can say why it is gone), createdAt, lastActivityAt (the expiry clock). See `POST /upload/staged`.

**devices** — id (uuid), profileId (FK profiles, cascade), name (what the phone is called on the owner's Phone page), keyHash (SHA-256 of the device key, unique — the key itself is answered once at pairing and never stored), pairedAt, lastSeenAt (touched by every `/shelf` call). A phone paired to one profile's shelf; `phone.forget` deletes the row and the key stops working at the next call.

**shelf_fetches** — id, documentId (FK documents, cascade), userAgent, country (from `CF-IPCountry` when a proxy sets it), at: a download from the **public** shelf (`PUBLIC_SHELF_PROFILE`, the Phone page's "Make it public"), where there is no device — analytics, never an address or an install id.

**shelf_downloads** — deviceId (FK devices, cascade), documentId (FK documents, cascade), downloadedAt; unique per pair. The owner's "on phones" column and the device's `downloaded` flag — the server counts downloads and never learns what was read. The whole phone shelf (pairing token, `/shelf/*`, the Phone page) is `docs/shelf.md`.

**chat_messages** — id (uuid), conversationId (FK, cascade), seq (unique per conversation), role (`user` | `assistant`), parts (jsonb — the AI SDK UI parts as streamed: text, tool calls with results, `data-sources`), status (`complete` | `stopped` | `failed`), error, model + modelLabel (what actually answered), createdAt.

**book_chunks** — id (uuid), bookId (FK, cascade), profileId + folderId (denormalized from books for index-friendly scoping, refreshed on reindex), source (`raw` | `chapter` | `translation`), bookFileId/chapterId/translationId (FKs, cascade — exactly the one matching `source` is set, translations also carry chapterId), language (variant key), seq, text, charStart/charEnd (true offsets into the source-unit text), pageStart/pageEnd (from `\f` form feeds for raw, chapter page range otherwise), sourceHash (sha256 of the unit's text and chunk/page layout — unchanged content skips reindex; metadata-only changes preserve existing embeddings), tsv (generated `to_tsvector('simple', text)`, GIN-indexed), embedding (`vector(1024)`, BGE-M3, HNSW cosine index, null until the embed pass), createdAt. Requires the pgvector image (`pgvector/pgvector:pg17`); extensions `vector` + `pg_trgm` are created in migration 0026.

When modifying the schema, change `schema.ts` and run `pnpm db:generate` to produce a migration, then `pnpm db:migrate`. Never write migrations manually.

## File Storage

All data lives in `./data/` (gitignored):

```
data/uploads/{bookId}/               Uploaded PDFs, or an imported EPUB's source.epub
data/tmp/{bookId}/                   Marker output (JSON inside a subdirectory named after the PDF)
                                     plus geometry.json (page/line/character boxes, see docs/read-along.md)
data/output/{bookId}/                Chapter M4As (ch000.m4a, ...; pre-switch books have .mp3), ch000.sync.json sync maps,
                                     timestamped assembly M4Bs and exported PDF/EPUB/readaloud files
data/output/{bookId}/{langSlug}/     Translation audio (per-language chNNN.m4a + sync maps)
data/output/{bookId}/chunks/         Per-chapter chunk WAV previews + chunks.json manifest
                                     (chunks/{variantSlug}/chNNN/ for variants); disposable once
                                     the sync map exists
data/previews/                       Voice preview M4As (global, shared)
data/pocket-voices/{id}.safetensors  Cloned Pocket TTS voice states + {id}.json metadata (global, shared)
```

Path helpers are in `lib/paths.ts`. The `DATA_DIR` env var defaults to `./data`.

**Important**: `data/tmp/{bookId}/` contains the raw Marker JSON with full block-level data including bbox coordinates. This should be preserved (not deleted) for potential re-processing with different settings (e.g., column filtering).

## Server Structure

```
packages/server/src/
  env.ts                Zod-validated environment variables (dotenv + schema)
  main.ts               Fastify entrypoint: file/audio/document download routes, tRPC plugin, static /files/
  upload-routes.ts      POST /upload and /upload/:bookId (multipart) — always queues rawExtract; extract only when fullExtract
  translation-stream-routes.ts  GET /translations/:id/stream — SSE relay of live variant generation deltas
  api-routes.ts         External JSON API (/api/books…) for scripts creating synthetic books/chapters
  mcp-routes.ts         MCP over Streamable HTTP at /mcp — stateless, one server per request, Host checked against lib/cors.ts
  script-run-routes.ts  GET /scripts/hn-top10/stream — spawns the HN script, streams output as SSE
  shelf-routes.ts       GET/POST /shelf/* — the read-only surface a paired phone reaches with its device key (docs/shelf.md)
  db.ts                 Drizzle postgres connection
  schema.ts             Drizzle table definitions (source of truth for DB schema)
  trpc.ts               tRPC init (router, publicProcedure, x-profile-id context)
  router.ts             Root tRPC router: books, folders, profiles, chapters, bookFiles, variants, notes
  routes/
    books.ts            Library + book lifecycle + digests + exports (see tRPC Routes below)
    chapters.ts         Chapter CRUD, selection, text edits, cleanup, AI prompts, audio deletion
    bookFiles.ts        Source-file selection, re-extract, skip-synthesis, cancel
    bilingual.ts        Two-language preparation (pairs, word links), its status and export readiness
    variants.ts         Variant runs (translations + transforms, createTransform, presets), per-variant audio synthesis/assembly, audio deletion
    folders.ts          Folder CRUD (profile-scoped), recursive delete
    profiles.ts         Profile (workspace) CRUD
    notes.ts            Per-book notes list/delete
    chats.ts            Library-chat conversations: list/get/create/rename/delete, book options for the source picker
    phone.ts            The Phone page: pairing code + QR, paired devices, the profile's shelf and hiding (tRPC `phone`)
  workers/
    setup.ts            Graphile Worker runner, task wrappers with console logging, five pools
    raw-extract.ts      pdftotext raw text per file (seconds); chains bookNote when requested
    book-note.ts        Upload-time AI prompt against whole-book raw text → note
    digest.ts           Digest book builder: per-source AI summary → suspended chapter + source note
    extract.ts          Marker PDF extraction + chapter detection
    normalize.ts        Text normalization
    synthesize.ts       TTS synthesis (skips suspended, writes progress + sync map)
    synthesize-translation.ts  TTS for finished translations (per-language audio + sync map)
    translate.ts        Per-chapter LLM variant generation (translation or transform)
    translate-titles.ts Backfill translated chapter titles (translation lanes only)
    cleanup.ts          LLM OCR-artifact cleanup (writes customText)
    propose.ts          LLM chapter-boundary proposals (crafting view)
    redetect.ts         Chapter re-detection without re-running marker
    assemble.ts         M4B assembly with chapter markers + cover
    assemble-document.ts PDF/EPUB (Vivliostyle), epub-sync (readaloud) and epub-bilingual exports
    prepare-bilingual.ts alignBilingual (sentence pairs) and linkBilingual (word links)
    sweep.ts            Startup sweep for stranded jobs
  lib/
    log.ts              appendLog() — writes to DB + console
    paths.ts            Data directory path helpers (uploadsDir, tmpDir, outputDir, previewsDir)
    marker.ts           Marker subprocess wrapper + chapter detection logic
    marker-sources.ts   Locate marker output dirs per source file
    llm.ts              Model registry (cloud providers + local OpenAI-compatible) and shared chat client (AI SDK)
    secrets.ts          The one table of user-settable API keys (LLM + cloud TTS) and the .env write path
    toc-detect.ts       LLM TOC-guided chapter detection (prompts + orchestration)
    toc-anchor.ts       Pure matching for it: title similarity, printed→PDF page map, entry placement
    chapter-rules.ts    Preface/oversized/label-sized word thresholds for detection
    chapter-anchors.ts  Browser-safe: title-like lines, locating a saved chapter in the block list (crafting view)
    structure-plan.ts   Pure: what new boundaries do to the saved chapters (kept / new / removed, edit decisions)
    structure-apply.ts  Executes a plan: rows, index-named files carried along, new chapters suspended
    pdf-raw-text.ts     Whole-document pdftotext wrapper
    book-raw-text.ts    Concatenate per-file raw texts for whole-book AI calls
    book-source-text.ts Best-available text per book for digests (chapters, else raw)
    token-estimate.ts   Pessimistic token estimate for context guards
    notes.ts            saveNote() shared by AI prompt paths
    chats.ts            Conversation store for library chat: scope resolution, run registry, question/answer persistence
    cleanup.ts          LLM chunk prompts for OCR-artifact cleanup
    translate.ts        Translation chunk/title prompts
    transform.ts        Shared variant engine: splitIntoChunks, transformChunk, per-kind strategy, custom-label inference
    transform-presets.ts Rewrite presets (eli5, shorten, summary, enrich) — edit prompts here
    translate-live.ts   In-process pub/sub for live variant streaming (run-fenced sessions)
    api-books.ts        External-API core: create api books, append source-tagged chapters, status
    pdf-books.ts        Create a PDF book from files already in its upload dir (shared by /upload and the MCP upload_book)
    epub-import.ts      Pure EPUB reader: package, table of contents, chapter slicing, print furniture stripped
    ebook-books.ts      Create an ebook book from an uploaded EPUB
    mcp-server.ts       The MCP tool set: ~20 curated tools over appRouter.createCaller, plus wait_for_book polling
    mcp-library.ts      Profiles and folders by name for the MCP tools: resolveProfileId, resolveFolderId (path from the top level), folder paths with counts
    voice-catalog.ts    The static voice table (Kokoro, narrators) and runtime-voice converters; dependency-free, the web picker imports it
    voice-list.ts       Every usable voice in one flat list: catalog + installed macOS/Pocket + cloud libraries behind a key (MCP list_voices)
    tts.ts              Voice registry + synthesis dispatch (kokoro / mms / bgtts / piper / say / pocket / loudkit / cloud)
    tts-chunks.ts       TTS text chunking: a chunk never crosses a blank line; SENTENCE_CHUNKS for local
                        engines, PARAGRAPH_CHUNKS (≤1000 chars) for Cartesia/ElevenLabs, which time words themselves
    kokoro.ts           Kokoro TTS subprocess wrapper with onProgress callback
    ffmpeg.ts           FFmpeg WAV→M4A encode and M4B concat/chapter helpers
    normalizer.ts       Text cleanup rules for TTS input
    sync-map.ts         Text↔audio timing maps (chNNN.sync.json) from chunk WAV durations + word timings
    cues.ts             Sync map → highlight cues (a sentence where words are known, else the chunk)
    readaloud-epub.ts   EPUB 3 Media Overlays builder for epub-sync exports (flat layout)
    epub-reader-layer.ts Adds the p2af reading layer (and bilingual documents) to a built EPUB
    bilingual-*.ts      Two languages: segment (sentences/tokens), align (pairs), links (word-link prompt),
                        store (row-locked publication), timing (offset → ms per recording),
                        document (the reader's document), export, format (the wire schema), audio
    owed-jobs.ts        A book's jobs still queued, waiting or running — failed ones excluded
    document-html.ts    HTML rendering for Vivliostyle document exports
    vivliostyle.ts      Vivliostyle CLI subprocess wrapper
    chunk-previews.ts   Chunk WAV preview listing + text locating (read-along in the web UI)
    chapter-artifacts.ts  Removal of a chapter's audio/sync artifacts from disk
    page-geometry.ts    Page/line/character boxes via scripts/page_geometry.py; content + column boxes, median body pt
    cue-rects.ts        Cue text → the rectangles it occupies on the page (see docs/read-along.md)
    reader-doc.ts / reader-routes.ts  The read-along documents: /read/book/:id/book.json and
                        /read/chapter/:id/cues.json — all the web reader is allowed to read
    folders.ts          Folder subtree/ancestor recursive CTEs
    delete-book.ts      The only correct way to delete a book (DB row + disk dirs)
    disk-usage.ts       Per-book disk usage measurement with cache
    insert-chapters.ts  Insert suspended chapters (digest + boundary apply); resetChaptersKeepingInserted (rebuilds keep source-tagged chapters)
    extract-registry.ts In-memory registry of running marker subprocesses (for cancel)
    pairing.ts          Ten-minute single-use pairing tokens bound to a profile, in memory; the pair link's shape
    reachable-address.ts Where a phone can reach this server, best first: Tailscale name (else tailnet address), then each LAN address — bridges and tunnels left out
    shelf.ts            The derived shelf: a profile's epub-sync/epub-bilingual documents, edition labels, device keys
    shelf-address.ts    Where a phone reaches the shelf: PUBLIC_ORIGIN behind a proxy, else the machine's own network; the machine name
    document-narration.ts  A document's narration lanes (level, duration, voices) at export, the startup backfill, the shelf summary
    synced-epub.ts      Pure: cue documents back into sync maps and chapter text; what a read-along export is on the shelf
    synced-epub-books.ts  A read-along export imported as a finished book — audio out of the zip onto disk, the file as the shelf document
    content-disposition.ts  The download header, UTF-8 filename included (main.ts and the shelf share it)
```

## Frontend Structure

```
packages/web/src/
  main.tsx              React root, tRPC/QueryClient providers (x-profile-id header), BrowserRouter
  trpc.ts               tRPC React client (imports AppRouter type from server)
  styles.css            Tailwind v4 import + semantic CSS custom properties; light-dark() pairs
                        switched by color-scheme, so a stored preference can pin either theme
  lib/
    voices.ts           Voice list across all TTS engines
    reader-doc.ts       Types + fetchers for the /read documents, cue lookup, crop math, legibility fit
    format.ts           Shared date/duration/size formatters + document format labels
    languages.ts        Translation language list
    ai-presets.ts       AI prompt presets (digest, notes, "Did you know?")
    book-sort.ts        Book list sort keys persisted in localStorage
    profile.ts          Active profile id in localStorage → x-profile-id header
    theme.ts            Appearance preference (auto/light/dark) in localStorage → data-theme on :root
    dnd.ts              Drag-and-drop payloads for book/folder moves
    row-click.ts        rowClick(action): a table row's main action on a click anywhere in it — the chapter
                        row opens the chapter, the source-file row previews the PDF. Controls inside
                        the row keep their own clicks, and a click that ends a text selection is ignored
    use-body-scroll-lock.ts  Modal scroll lock hook
  pages/
    Home.tsx            Library shell: header (profile, chat, reader, appearance, settings), crumb +
                        add bar (upload modal, new folder), filter chips + search, book/folder list
    BookDetail.tsx      Per-book orchestration: staged sections (1 Input → 2 Work → 3 Output → danger zone), variant view (translation or rewrite) in ?variant= query param
    PhonePage.tsx       /phone: the QR that adds this profile's shelf to a phone, the paired phones (Forget), the shelf
                        table with hide — the one page outside the library that carries LibraryHeader
    Components.tsx      /components gallery: every token, primitive and icon on one screen, derived from
                        styles.css and icons.tsx so it cannot drift. Static — renders with the server down
    Reader.tsx          Read-along reader (/books/:id/read): Column/Page/Text views over one timeline,
                        phone width presets + legibility readout, tap-to-seek, Back inside the pinned bar (a chapter is
                        thousands of pixels of pages), `?t=<ms>` to open at a moment of the narration, rect/layout debug toggles.
                        Lazy-loaded so no other page pays for pdf.js; reads only the /read documents
  components/
    book/               The book page's shell. BookShell.tsx (pinned header/tabs/tray/dock, one scroll
                        pane, useShellLayout width context) + TabPanel (inactive tabs stay mounted and
                        hidden — unmounting ChapterTable clears nine filters and stops playback);
                        StageTabs.tsx, BookHeader.tsx, VariantMenu.tsx, ExportModal.tsx,
                        BookDetailsModal.tsx, ResourceRow.tsx
    library/            The library page's shell. LibraryHeader.tsx is the **app bar on every page** — the
                        library, /phone and a book: name (a link home off the library), profile, Phone, then
                        assistant, theme, settings; a page's own row sits under it (the library's crumbs with
                        Open EPUB / Drop PDFs / New folder, the book's crumbs, neighbours, title and actions in
                        BookHeader.tsx) — before 2026-10-11 the book page drew one row of its own with the
                        toggles in different places. LibraryShell.tsx (pinned header/bar/filters, one
                        pane that is also the PDF drop target, useLibraryLayout width context),
                        LibraryFilters.tsx (chips + search + showing-count), UploadModal.tsx
    BookFilesSection.tsx    Tab 1 body: source-file table, add files, re-extract, extraction settings
    AudioOutputsSection.tsx Tab 3: produced audiobook assemblies (play/download/delete)
    DocumentOutputsSection.tsx Tab 3: exported PDF/EPUB/synced-EPUB documents
    NotesSection.tsx    The Notes tab: saved AI answers (markdown, copy, add as chapter, delete)
    LogDock.tsx         Bottom log bar (a flex child of the shell, not fixed) + full log modal
    EditableTitle.tsx   Click-to-rename book title
    ChapterTable.tsx    Chapter table — quick-filter chips, title search, the rest behind a Filters
                        popover; sticky header over its own scroller (the pinned filter bar is why the
                        table scrolls rather than the tab), range selection, floating audio player
    ChapterFilterPanel.tsx  The Filters popover: status chips, voice (incl. "not the current voice"), when made,
                        words and length; the predicates are pure in lib/chapter-filters.ts
    SynthesizeModal.tsx Voice/speed picker + start button — behind the toolbar's Synthesize action
                        for the selection, and behind every single-chapter re-synthesize (row icon
                        and chapter modal), which is where a chapter's voice is chosen
    ChapterModal.tsx    Chapter detail modal: view tabs — Pages is the book's own scan, offered only
                        while the print still holds this chapter's words (never on a variant); Text is
                        the spoken prose and is always offered; Source is the extracted text, Compare
                        puts the two side by side, Blocks is the extraction diagnostic. Pages/Text
                        used to be one "Read" tab that chose for you, which left a chapter with no
                        clean text reachable only through the Edit box. Also text editing and the
                        per-chapter actions. Every text pane marks the narrated sentence and word
                        (lib/text-cues.ts locates the cues in it)
    ChapterCrafting.tsx Chapter crafting: outline + source (pages/text) + PDF pane, starts at any block, undo, preserving apply
    VariantModal.tsx    Variant start/progress modal: language + rewrite-preset + custom-prompt targets, live side-by-side view
    DigestModal.tsx     Create-digest modal (prompt presets, text-availability warnings + exclusion)
    FolderPickerModal.tsx Move-to-folder tree picker, with other profiles as destinations for books
    Breadcrumbs.tsx     Droppable folder breadcrumbs
    BookSearchResults.tsx Search results across all folders with folder-path breadcrumbs
    BookList.tsx        Books overview table (activity pills, no-text pill, languages, outputs, size)
                        with polling, per-row Read + overflow, and the library's pinned ActionTray
    ProfileSwitcher.tsx Profile dropdown in the Home header (create/rename/delete)
    ThemeToggle.tsx     Appearance menu in the Home header (auto/light/dark)
    UploadZone.tsx      Drag-and-drop PDF upload; one book or many, a title, nothing else — it lands on the book with Extract open. Lives
                        inside library/UploadModal.tsx; also ingests a drop the library pane caught
    ActionTray.tsx      Pinned selection summary + verbs + a primary slot, shared by both shells
    BookKindBadge.tsx   digest / api label, so the list and the search results say it the same way
    PdfPreviewModal.tsx Inline source-PDF preview
    reader/             PdfCanvas.tsx (pdf.js page or column crop, rendered near the viewport),
                        CueOverlay.tsx (highlight + debug boxes), BilingualReader.tsx (paired
                        paragraphs, word hover/seek, alternating playback), BilingualPassage.tsx,
                        WordMeaning.tsx (the equivalent shown on hover or hold)
    BilingualSelection.tsx  The tray's Bilingual reading panel: pair and link the selected chapters
    BilingualPreparation.tsx The same for one chapter, inside the chapter modal
    DiskUsage.tsx       useDiskUsageTotal (labels the book-menu item) + DiskUsageModal (breakdown + chunk cleanup)
    Menu.tsx            The one popover — book menu, variant picker, tray overflow, row menus. Portalled
                        (into the enclosing dialog, else the body) and positioned `fixed` from the trigger,
                        flipping sides when it would leave the viewport — an absolute panel inside a
                        table's overflow wrapper was clipped on the last rows. Its outside-click is
                        swallowed, or dismissing a menu inside a dialog would close the dialog too
    SegmentedControl.tsx  Promoted out of Reader.tsx; "raised" and "accent" skins
    MarkdownBlock.tsx   Markdown renderer for notes/AI answers
    VoicePicker.tsx     Trigger for the voice library modal (labelled field); queries only the engine owning the current selection to resolve its label
    voice-picker/       VoiceLibraryModal.tsx — **language is the primary axis**: the rail lists languages (plus "Your voices" for clones), the pane groups that language's voices by engine, so "what can read my French book" is one click instead of five tabs. Every voice carries `language`/`engine` (set by the mappers in lib/voices.ts; `staticVoices` decorates the literals). An ElevenLabs voice appears under every language its model reads, marked not native where it was not verified. Within a language the pane groups by **provider** (`providerOfVoice` in lib/voices.ts — finer than `engine`, so the Bulgarian narrators are named rather than lumped as "other local models") with filter chips: the chip defaults to the provider of the current selection, typing forces "All" so a search can't silently miss behind a filter, and the combined view caps each provider at 6 rows behind "Show all N" because Cartesia alone can contribute 450 voices to one language. Two toggles beside the search, **Word timing** and **Speed control**, keep only the voices that have them — word timing judged in the open list's language (`voiceHasWordTiming`: ElevenLabs and Kokoro have none in a language written without spaces) — and while one is on the rail's counts follow it, its order does not. PocketLanguageNotice.tsx (download prompt + size), PocketVoiceCloner.tsx (record/upload + consent), VoiceRow.tsx, context.tsx (selection + preview playback), layout.tsx
    SpeedSlider.tsx     Speed range slider (0.5x-2.0x)
    StatusBadge.tsx     Color-coded status badge
    Modal.tsx           Shared dialog shell: sizes, Escape, focus trap + restore, header that names the dialog
    PillToggle.tsx      Shared selected/unselected pill (aria-pressed) — 9 call sites
    Button.tsx          The one action primitive — variants, sizes, and the disabled-link rule; see "Buttons" above
    icons.tsx           The only file importing @phosphor-icons/react; see "Icons" above
```

### Dark Mode

Both themes come from the same semantic tokens in `styles.css` — components never carry a `dark:`
variant whose only job is a colour. A token that differs between themes is one `light-dark()` pair,
so there is no second block to keep in sync; which half applies is decided by `color-scheme` on
`:root`. See **Colour and reading surfaces** above for the palette/semantic split and what enforces it.

Appearance is a user preference, not just an OS one: `lib/theme.ts` stores `auto` / `light` / `dark`
under the `theme` localStorage key. `auto` leaves `:root` with **no** `data-theme` attribute — that
is what lets `color-scheme: light dark` keep following the OS — and the other two set it, which is
why a pinned theme wins over the OS query where the old media block could not. Pinning also corrects
native scrollbars and form controls, which a token swap alone never reached.

The attribute is stamped **before first paint** by an inline `<script>` in `packages/web/index.html`:
the app's module script is deferred and sits behind a 1.4 MB bundle, so applying the preference from
`main.tsx` would flash the OS ramp on every launch. That copy and `lib/theme.ts` share the `theme`
key by hand; change it in both or neither.

Accents are tokenised like everything else (`--accent`, `--danger`, `--success`, `--warning`, each
with its `-text`, `-hover` and `--on-*` partners), and the log dock is on `--bg-terminal`, not a
fixed zinc.

## tRPC Routes

**books** (library): `list` ({folders, books} for one folder, profile-scoped, activity/failure/size stats + recursive folder rollups + hasText/hasPages flags + per-book `filterState` and per-folder subtree `filterCounts`) · `search` (title words across all folders, returns folder paths) · `textAvailability` (which books have chapters or raw text — digest precheck) · `get` · `logs` / `clearLogs` · `rename` · `updateSettings` · `moveToFolder` · `moveToProfile` (lands unfiled; the book's notes and search chunks follow it) · `delete` / `deleteMany` · `diskUsage` / `cleanupChunks`

**books** (pipeline): `upload` (legacy tRPC path) · `retry` (full re-extract; `forgetTextLayer` drops the OCR copies first) · `extractChapters` · `processSelected` · `cancel` · `assemble` · `assemblies` / `deleteAssembly` · `structure` / `proposeChapters` / `applyChapterBoundaries` / `redetectChapters` · `chapterList` · `rawTextStats` (Ask AI itself is the assistant's `analyze_text` tool)

**books** (synthetic + exports): `createDigest` / `resumeDigest` · `exportDocument` (pdf | epub | epub-sync) · `pendingDocumentExports` · `documents` / `deleteDocument`

**chapters**: `get` · `synthesisCost` (credits a metered voice would charge for what Start sends — `lib/synthesis-cost.ts` owns which chapters that is, shared with `books.processSelected` and `variants.processSelectedAudio` — plus the ElevenLabs balance; Cartesia reports none to an API key) · `queue` / `suspend` · `setSelected` / `setSelectedBatch` / `setAllSelected` · `rename` · `reorder` · `updateText` / `resetText` · `queueCleanup` / `stopCleanup` / `cleanupSelected` · `textStats` · `selectedAudioSize` / `deleteAudioSelected` · `deleteSelected`

**bookFiles**: `setSelected` / `setSelectedBatch` / `setAllSelected` · `setSkipSynthesis` · `remove` · `reExtract` / `reExtractSelected` · `cancel`

**variants**: `presets` · `get` / `detail` / `listForBook` / `list` · `start` / `createTransform` / `stop` / `processSelected` · `translateMissingTitles` · `queueAudio` / `processSelectedAudio` / `stopAudio` · `selectedAudioSize` / `deleteAudioSelected` · `assemble` — all keyed by `key` (variant key), not `language`

**folders**: `list` / `create` / `rename` / `move` / `path` / `deleteStats` / `delete` — profile-scoped; `move` rejects subtree cycles; `delete` is recursive (books via `deleteBook`)

**profiles**: `list` (marks the default) / `create` / `rename` / `delete` (refuses default and non-empty profiles)

**ocrTry**: `page` (renders one page of a file at 150 dpi under `data/tmp/{bookId}/ocr-try/`, plus a 300 dpi copy for OSD; returns page count, size in points, image URL, detected script, its candidate packs, installed packs, whether the Surya bundle is present) · `tesseract` (one page through `tesseract … tsv`: lines with per-word confidence and boxes in points, mean confidence, doubted share, and the callout — `clean` / `edge` with side and band / `scattered`, from `classifyDoubt` in `lib/ocr-try.ts`)

**ocrLanguages**: `list` (every pack in the pinned tessdata_best manifest — name, bytes, book language, installed, in-flight download) · `download` (one file, checksummed, atomic) · `remove` (refuses `eng`)

**chats**: `list` (profile's conversations newest first — scope and cited books with current titles and `available`, every question asked for the sidebar search, answer count; never-asked ones hidden) · `get` (scope resolved against today's library, messages as opaque parts + `{status, error, modelKey, modelLabel, createdAt}`, plus the live partial and `running` while an answer is being written) · `bookOptions` (id, title, author of every book in the profile) · `create` (scope validated against the profile, titles snapshotted) · `rename` · `stop` (the only way to end an answer early; returns once the run has let go) · `delete` (ownership checked before the run is touched, then aborts a running answer; books and saved notes untouched) — all profile-scoped

**notes**: `list` (per book, newest first) / `delete` / `toChapter` (append the note as a suspended chapter, `source {kind:"note"}`; refuses library notes) / `saveLibraryAnswer` (persist a library-chat answer as a book-less note, profile-scoped)

**bilingual**: `chapterStates` (one translation's per-chapter step — `bilingualChapterState` in `lib/bilingual-chapter-state.ts`, from no translation through pairing to words linked — which the chapter table's Bilingual column and filter, the tray and the preparation dialog all read, so they cannot disagree) · `readiness` (per translation: selected chapters, how many are paired and how many fully linked) · `selection` / `prepareSelection` / `cancelSelection` (the tray's batch over selected chapters, `stage: "pairs" | "links"`, current work skipped) · `status` / `prepare` / `cancel` (one chapter) · `exportStatus` (paired chapters vs ones that still need it, for the export dialog and the assistant) · `position` (a time in the original narration → the matching time in the translation's) · `convertAudio` (legacy MP3 recordings → M4A, originals kept)

**search**: `library` (hybrid FTS + vector search over `book_chunks`, profile-scoped, optional folder subtree scope, RRF fusion + cross-language grouping — see Library Chat below) / `indexStatus` (per-profile index coverage counts for the chat UI hint)

**phone**: `pairingCode` (mints a profile-bound token, answers the QR as an SVG data URL plus the reachable address; `code: null` with `loopbackOnly` while `HOST` is loopback, `reachable: null` with no network address, or `access: "none"` while the network may reach nothing) · `listenOnNetwork` (writes `HOST=0.0.0.0` to `.env`; a restart applies it) · `setNetworkAccess` (`none` | `shelf` | `all`, written to `.env` and applied live) · `setPublic` (marks the caller's profile the one public shelf, `PUBLIC_SHELF_PROFILE`, live) · `devices` / `forget` · `shelf` (the profile's shelf files, hidden ones included, with who fetched each) / `setHidden` — all profile-scoped

## HTTP Endpoints (non-tRPC)

- `GET /ocr/try/:bookId/:fileIndex/:page.png` — the rendered page the Try one page screen shows; `GET /ocr/try/:bookId/:fileIndex/:page/surya` — SSE, one page through `scripts/ocr_surya.py --page N --stream-lines`, the script's JSON events forwarded verbatim plus `{event:"error"}`; closing the stream SIGKILLs the run. Both registered by `lib/ocr-try-routes.ts`; the Vite proxy forwards `/ocr`.

- `POST /upload` — Multipart file upload (PDFs + settings fields; `x-profile-id` header assigns the profile). Creates book + book_files rows, queues rawExtract (+ extract when fullExtract).
- `POST /upload/ebook` — Multipart, one `.epub` (plus optional `title`, `folderId`): an `ebook` book with its chapters from the table of contents, or 400 naming why the file cannot be read (not an EPUB, DRM-protected, no text). A Libratory read-along export (it carries `p2af/book.json`) instead comes back as a **finished** book with its narration restored and the file on the shelf (`lib/synced-epub-books.ts`, `docs/shelf.md`) — the exchange format between a machine that synthesizes and a server that only serves
- `POST /upload/:bookId` — Append PDFs to an existing book
- `GET /pdf/:fileId` — Serve a source PDF (inline preview)
- `GET /read/bilingual/:variantId.json` — One translation's `p2af-bilingual/1` document for the in-app reader, built live from `bilingual_preparations` and the current sync maps; 409 with the reason when it cannot be built
- `GET /download/:bookId` — Serve final assembled audiobook
- `GET /download/assembly/:assemblyId` — Serve a specific assembly
- `GET /download/document/:documentId` — Serve an exported PDF/EPUB/synced-EPUB document
- `GET /audio/chapter/:chapterId` — Serve individual chapter audio
- `GET /audio/translation/:translationId` — Serve translated chapter audio
- `GET /audio/assembly/:assemblyId` — Stream an assembly
- `GET /preview/:voiceId` — Voice preview M4A (generated on demand, cached in data/previews)
- `GET /files/*` — Static mount of the whole output dir (chunk WAV previews, direct file access)
- `POST /api/books` / `POST /api/books/:bookId/chapters` / `GET /api/books/:bookId` — External JSON API for scripts and other projects (`api-routes.ts` + `lib/api-books.ts`, full reference in `docs/synthetic-books-api.md`): create synthetic `kind:"api"` books, append source-tagged chapters to any book (rebuild-safe), poll synthesis status. Optional `synthesize: true` queues TTS per chapter (text is normalized inline at insert); optional `x-profile-id` scopes like the web app.
- `POST /mcp` (also GET/DELETE, answered per the MCP spec) — MCP server over Streamable HTTP (`mcp-routes.ts` + `lib/mcp-server.ts`, tool reference in `docs/mcp.md`). Stateless: each request builds a `McpServer` and transport and tears them down on response close, so an app restart strands no session. Tools call the tRPC procedures in-process through `appRouter.createCaller({ profileId })` — the profile comes from the same `x-profile-id` header as everywhere else — so validation and behaviour are the router's, never a second copy; `upload_book` is the one tool with its own body, because it takes local paths (copied into the upload dir, then `lib/pdf-books.ts` like the multipart route). Every long job returns at once with a *summary* (`summarizeBook`: counts, and in detail only what failed — `get_book` is the one tool that lists chapters, because an agent pays per token and polls in loops); `wait_for_book` polls `books.get` every 2s until a stage (`text`/`searchable`/`chapters`/`translation`/`audio`/`bilingual`/`document`/`output`) or its timeout, returning early on failure — a failed translation, a translation's failed narration and a failed pairing included; without `language` only a failure from the last hour ends the wait, because narrating the original once reported a Bulgarian narration that had failed a month before. `text` means every file has been read, OCR included — it reads the job queue (`queuedWork`) as well as the book, because between a raw extraction ending and the OCR job starting the book looks idle, and one file with text used to satisfy it for a seventeen-file book while the first scan was on page 11. The profile header is fixed when a client is configured, so the tools that place or list things also take `profile` and `folder` by name (`lib/mcp-library.ts`; a folder is a path from the top level, created when a book is being placed and never for a filter). `create_book` is `lib/api-books.ts` behind a tool — text in, searchable and narratable book out — and `save_note`/`list_notes` are how an agent leaves what it worked out where the person and the next session will find it. No Origin exists to judge here, so `isTrustedHost` (loopback, any IP literal, or `TRUSTED_HOSTS`) is the whole rebinding check. Keep the tool count near 20 (25 today — `manage_folder` creates, renames and moves folders and never deletes; the log folded into `get_book` to make room; `translate_book` was added on 2026-09-26 because with no such tool the assistant answered "a German version" with `export_book language: de`, the one place the word translation appeared) — agents degrade past that — and add one only with a description that says when to use it. `get_capabilities` + `start_download` exist because the UI's bundle/pack gates (`useModelBundle`, the OCR pack picker) do not exist on MCP: `upload_book`/`extract_book` refuse full extraction until the `extraction` bundle is installed and name the download instead of failing offline in Python.
- `POST /assistant` — The assistant panel's answers (`assistant-routes.ts`): the same stored conversations and server-owned run as `/chat` (`lib/chat-run.ts` `deliverAnswer` is the shared half), with the MCP tool set in place of the search tools — `lib/assistant-tools.ts` connects `createMcpServer` over an in-memory transport and hands the model the tools whose tier it is allowed (`TOOL_TIERS`: read / quick / confirm / spend; a test fails on a tool the table does not name), and which calls wait for a yes is `needsApproval`, the SDK's `toolApproval` hook, decided per call (`tierOf` in `lib/assistant-tiers.ts`, dependency-free so the web panel draws a call as a trace, a Done card or a confirm card from the same table): read and quick tiers run in the turn, confirm and spend end the turn as an `approval-requested` part the panel shows as a card. Run or Cancel comes back as `trigger: "approval"`; the server marks the stored part responded and generates again from the same message (`deliverAnswer`'s `continueFrom` — the stream carries that row's id, the SDK executes the approved call itself, and the kept copy replaces the row), so a card and what followed it are one answer. A card walked past is answered no on the person's behalf before the next question. Quick tools answer with the call that reverses them (`undo` in the result: a rename, a move, an author, a folder rename or move) and `POST /assistant/undo` runs it as the server, no model turn. Message ids are the stream's, generated by the server, so the browser, the stream and the row agree. `show_in_app` is the one tool the panel has and `/mcp` does not (an agent has no screen): a static AI SDK tool in `lib/assistant-tools.ts` whose input is a place in the app — the library, a folder, a book tab, one of the book page's four dialogs, a chapter, the reader at a moment — turned into a URL by the dependency-free `lib/assistant-targets.ts`; the panel follows the call as it lands (`navigate`, once per call, never for a restored transcript) and draws it as an "Opened …" line. The book page opens a dialog from `?dialog=extract|structure|synthesize|export` (consumed on arrival, `?extract=1` kept as the old spelling) so the panel can open one under a page that is already mounted. After a reload a card still waiting is checked against the book: one whose `updatedAt` is later than the card's message is drawn "Out of date" with no buttons. A thread being answered by a run this window did not start is polled (`chats.get` every second while `running`) and the transcript is the server's until it ends, when the thread remounts from what was kept. A stream that ended with neither words nor a call is kept as failed, so the person sees an ending and a Retry rather than a blank. The panel also has the library chat's search: `assistantTools` takes the chat's `search_library` and `read_passage` (built by `buildChatTools` with a `CitationCatalog`, scoped to the book on screen or the profile) in place of the MCP `search_library`, the route verifies the answer's `[c_N]` ids and writes `data-sources` exactly as `/chat` does, and the thread renders them with the chat's `SourceList` — so a "where does it say" question in the panel gets links into the page or the reader. The model's reasoning is shown as a collapsed "Thinking…" line (the `thinking-sweep` keyframes in `styles.css` while it streams). Several dropped files carry a one-or-many choice (`BundleChoice`, sent as a sentence in the question) and can be reordered; a dropped folder is walked for PDFs by `droppedPdfs` in `lib/dnd.ts`, shared with the upload zone. The body carries one question, the page (`screen`: route and book id, summarised into the system prompt by `lib/assistant.ts`) and any `staged:` references. Since 2026-09-25 the panel is the one chat: the `/chat` page and the "Chat" links on the library and book headers are gone (the Assistant toggle beside them is the way in; the book's search-index dot moved onto the panel's sources chip, `lib/search-index-state.ts`), past threads and the "Saved answers" list are behind the panel's History button (`HistoryModal`), every conversation is the panel's (0043 and 0044 moved the library chat's over, 0045 dropped the `kind` column that told them apart), and Ask AI's presets are chips on a book page that become `analyze_text` cards (`lib/assistant-tools.ts`: `buildAskContext` → `generateText` → `saveNote`, the answer drawn as a note with "Add as chapter"). A thread's sources are chosen once (`SourcesModal`: follow the page, or the chat's `SourcePicker`; the header chip names what a question would search right now — the book on screen or "Whole library" — never the rule, because "Follows the page" read as a mystery) and a new thread started inside one carries them minus what is gone (`draftFromScope`); one whose sources are all gone is read, not continued (`askable` → `OpenThread.closed`). "Save as note" under a finished answer is `notes.saveLibraryAnswer`, as on the old page.
- `POST /upload/staged`, `DELETE /upload/staged/:id` — A PDF dropped on the assistant panel (`staged-routes.ts`, `lib/staged-files.ts`): streamed to `data/tmp/staged/<profile>/<id>.pdf`, hashed, refused unless it starts `%PDF-`, capped at 2 GB per profile (uploads run one at a time per profile, because the cap is a read of the rows plus the stream in hand and files dropped together would each pass it alone), answered with `staged:<id>`. `inspect_pdf` and `upload_book` take that reference in place of a path (the model never sees a path); `upload_book` copies the file into the book and marks the row `used`. A file is claimed by the thread that first sends it (deleting the thread deletes it), expires a day after that thread's last use, and `sweepStaged` (at boot and hourly) settles expired rows, files with no row and rows with no file.
- `GET /shelf/pair/:token`, `POST /shelf/pair`, `GET /shelf`, `GET /shelf/documents/:id` — The phone shelf (`shelf-routes.ts`, `lib/shelf.ts`, contract in `docs/shelf.md`): a paired device lists one profile's finished read-along and bilingual EPUBs and downloads them, with its device key as the only credential — no cookie is read under `/shelf`, so a browser page cannot ride a session into it. Pairing is a single-use ten-minute token the Phone page mints (`lib/pairing.ts`), carried in the QR as `PAIR_LINK_BASE#s=<origin>&a=<fallback origin>…&t=<token>` — `s` is the address to try first (Tailscale), each `a` a LAN address for a device on the same Wi-Fi but not on the tailnet, and peek/pair answer the same list as `addresses`; peek is free, pair spends it and answers the key once. **`HOST` binds, `NETWORK_ACCESS` decides** (`lib/network-access.ts`, the first hook on the server, before CORS): what an address other than this machine may reach — `none` (default outside Docker), `shelf` (these four routes only), `all` (Docker's default: a container is a server you put behind a login of your own). It judges the *matched route*, never the URL as typed, and the socket address, never a forwarded header, so `/shelf/../trpc` and a spoofed `X-Forwarded-For` get the same 403 as everything else. Sharing a shelf is live from the Phone page; only the first `HOST` change needs a restart. The page mints nothing until a phone could use the code.
- `GET /scripts/hn-top10/stream` — Runs `scripts/hn-top10.mjs` as a subprocess and streams its output as SSE (`script-run-routes.ts`); backs the "HN digest" button/modal on the home page. Validated query params (date/count/synthesize/folder/profile), single-flight lock, child survives client disconnect.
- `GET /translations/:translationId/stream` — SSE live feed for a running variant generation (`translation-stream-routes.ts`): snapshot on connect, then `delta`/`thinking`/`status` events from the worker's in-process channel (`lib/translate-live.ts`). The modal's 1s polling stays as fallback.
- Ask AI has no route of its own any more: the book page's Ask AI buttons (header, the raw-text hint, the chapter tray, a chapter row, the chapter modal) pin the book or the chapters to the assistant (`pin` in `components/assistant/context.tsx`), the pinned text rides in the question as `Read the whole text of "…" (bookId …).` or `Read these chapters of "…": … (chapter …).` (`describeRead` in `assistant-routes.ts`, titles from the library, refused when gone), and the model answers with the `analyze_text` spend tool — `lib/ask-ai.ts` `buildAskContext` behind a card, the answer saved as a note and drawn as one. `POST /chat/ask` and `ChapterAiModal` were removed on 2026-09-25.

## Desktop app (`packages/desktop`)

Electron, and deliberately thin — the app is a local server and a page, so the shell starts child
processes and opens a window. Everything with logic in it is plain Node with tests, because a
display is not available in CI. `tasks/desktop-app.md` has the reasoning, `packages/desktop/README.md`
the state.

- **`src/docker.cjs`** — tested. Docker is found by probing install and socket
  locations for Docker Desktop, OrbStack, Colima and Rancher, **never by `$PATH`**: a Finder-launched
  app gets `/usr/bin:/bin:/usr/sbin:/sbin`, so `which docker` reports nothing on a machine running it.
  Each docker call is then handed a `PATH` built from those same install directories, because the CLI
  resolves `docker-credential-osxkeychain` through it — on a machine with a credential store, a first
  pull without that fails as "error getting credentials" instead of starting Postgres.
- **`src/main.cjs` / `src/setup.cjs`** — the window and the first run. Stages `scripts/`,
  `pyproject.toml` and `uv.lock` out of the bundle into `~/Library/Application Support/Libratory`,
  fetches a checksummed `uv`, builds the Python environment from the lockfile, fetches Kokoro,
  builds Piper's own environment (`python-piper`, keyed on `requirements-piper.txt`, optional — a
  failure costs the voice, not the app), brings up Postgres in Docker, applies the migrations,
  starts the compiled server. ~2.6 GB downloaded once, then the window loads it. Pocket has no
  environment in the packaged app; the server reports `LIBRATORY_RUNTIME` (`desktop`, `docker`,
  `source`) and its rows say "Not in the desktop app yet" rather than naming a setup command a
  packaged app cannot run. BgTTS is built on request from the voice picker with the first run's
  uv (`UV_PATH` = `HOME/uv/uv`), into `HOME/python-bgtts`, and loudkit the same way into `HOME/python-loudkit`. Steps are a list in `main.cjs` sent to `first-run.html`
  so the window draws itself; the runner blocks the step that failed and marks the rest skipped.
  A synced EPUB double-clicked in Finder arrives through `open-file` (declared as a `Viewer` at
  `Alternate` rank, so Books.app keeps the default) and is queued rather than opened: on a cold
  launch the event beats `ready`, and `/open` needs the server. Boot hands it over by loading
  `/open`, where the reader claims it through `takeOpenFile` — a pull, because that route is lazy
  and a push can land before it mounts. Ordinary EPUBs have no read-along layer and are refused
  there by `containerSource`.
  `runtime.cjs` compares the bundle's `uv.lock` hash against `runtime-state.json` and skips whatever
  is already current — a launch with nothing to bring forward is ~1s. `updater.cjs` checks GitHub
  Releases *after* the window is up. `crash.cjs` turns an uncaught exception into a `crash.log` line
  and a prefilled GitHub issue. Versions are `v<YY>.<MMDD>.<n>` — `v26.826.0` is the first release on 26 Aug 2026, `v26.826.1` the
  second that day. Three numeric parts with no leading zeros, because electron-updater parses both
  sides with semver and `isUpdateAvailable` is private; a 4th part is invalid and a `-2` suffix is a
  prerelease that sorts *below* the release it follows. Update checks run once at launch and every
  6h after — nothing is pushed, each check is a GET of `latest-mac.yml`.
- **One port.** The server serves the built web bundle (`WEB_DIR`, with an SPA fallback) when one
  exists, so a package needs no Vite. In the repo, unbuilt, nothing changes. `lib/spa-fallback.ts`
  holds the fallback as an **allow-list** of the routes `packages/web/src/main.tsx` owns — add a
  client route there too. It reads the other way round on purpose: the rule used to be "a GET with
  no extension is a client route", and `/pdf/:id` and `/audio/chapter/:id` have none, so a missing
  file came back as `index.html` with a 200 and drew every page of every book blank.
- **`scripts/bundle-tools.py`** — copies ffmpeg, poppler (pdftotext/pdfinfo/pdftoppm) and tesseract
  out of Homebrew with their dylib closure, rewrites load commands to `@loader_path`, and
  **re-signs ad-hoc**, without which Apple Silicon kills them silently. It also copies
  `eng`/`osd`/`pdf.ttf`/`configs`/`tessconfigs` into `resources/tessdata`, which `stageRuntime`
  copies (never replaces) into `HOME/tessdata` — an update must not take a downloaded pack with it. `scripts/make-icon.sh` builds the `.icns`.
- **`scripts/vm-verify.sh`** — run inside a fresh macOS VM (tart), asserts the absences first.
- **Linux** — `scripts/desktop-build.sh --linux` builds an AppImage (`bun-linux-*` server, no tools tarball: a first run names the apt/dnf/pacman command for missing ffmpeg/poppler/tesseract via `setup.installHint`, and Docker help speaks Linux). Written but not yet run on Linux; the open steps and the Windows list are in `tasks/desktop-linux-windows.md`.
- Updating the runtime, not just the shell, is planned in `tasks/desktop-updates.md` and not built.

## Library Chat & Search Index

**Full deep-dive: [docs/library-search.md](docs/library-search.md)** — chunking, FTS config rationale, embeddings runner, RRF/grouping, citation verification, indexing lifecycle + invalidation table, crash recovery, operations/troubleshooting. The section below is the summary.

Agentic RAG over the whole library (the assistant panel, docked beside every page): the picked model iteratively calls search tools over an indexed copy of every book's text, streams a cited answer, and each citation opens the source (PDF page / chapter / translation view). Scope: whole profile, a folder subtree, or a single book ("💬 Chat" on BookDetail → `/chat?bookId=…`). Conversations are kept (`chat_conversations` / `chat_messages`, see **Conversations** below) and listed in a history sidebar; an answer worth keeping on its own is still saved explicitly as a note (`notes.saveLibraryAnswer`, `bookId NULL`) and listed behind the sidebar's "Saved answers" button via `notes.listLibrary` — deleting a conversation never touches those. This is distinct from **Ask AI** (the assistant's `analyze_text` tool, reached from the book page's Ask AI buttons or the preset chips) which stuffs the full scope text into context with no retrieval — better for whole-book analysis, no citations.

**Retrieval** (`lib/search.ts`): one SQL statement fuses a Postgres FTS leg (`websearch_to_tsquery('simple', …)`, `ts_rank_cd`) and a pgvector leg (`embedding <=> query`, HNSW, `SET LOCAL hnsw.iterative_scan = 'relaxed_order'`) with reciprocal rank fusion (k=60, top 50 per leg). `groupHits()` then collapses near-duplicates in JS: max 2 passages per source unit, prefers the query's script (Cyrillic heuristic) among close-scoring language twins, drops raw-text hits whose pages duplicate an extracted chapter hit, caps 3 hits per book. Falls back to FTS-only when the embedder is down (`embedQuery` returns null). `expandPassage()` merges adjacent chunks via true char offsets (overlap deduped) for the `read_passage` tool.

**Embeddings** (`lib/embeddings.ts` + `scripts/embed_bge_m3.py`): BGE-M3 (multilingual, 1024-dim normalized dense vectors) as a lazy singleton child process in the Python env — JSON-lines `{id, texts[]} → {id, vectors[][]}` over stdin/stdout, kokoro-style spawn env (`HF_HUB_OFFLINE=1`), restart on crash, idle-kill after 5 min. Batch timeout 5 min, query timeout 20s (chat degrades to keyword search, never blocks).

**Chat loop** (`assistant-routes.ts` + `lib/chat-tools.ts` + `lib/citations.ts`): Vercel AI SDK `streamText` with the model resolved from `lib/llm.ts` (any configured provider; models without tool support are rejected for chat), `stopWhen: stepCountIs(6)`, 3-min abort signal, 4096 max output tokens, and a `prepareStep` that on the last step removes the tools (`activeTools: []`) and appends a user message saying the search rounds are spent and the answer is due now, so the turn always ends with a text answer instead of closing silently after tool calls. It first declined them with `toolChoice: "none"`, and DeepSeek answered that by writing the calls it still wanted as raw DSML markup — the tools have to be absent from the request. Tools: `search_library` (hybrid search, registers hits in a per-request `CitationCatalog` as `c_1…` ids), `read_passage` (neighbor expansion by citation id), `list_books` (titles only). The model must cite `[c_N]` inline; after streaming, `verifySources()` keeps only catalog-known ids (toc-detect discipline — hallucinated ids are dropped) and emits one `data-sources` part the UI renders as a source list; on the way out `withReaderTargets` (`lib/citation-targets.ts`) gives each cited passage of a narrated chapter a `readAt` — the chunk's `charStart` located among the sync map's cues with `locateChunks`, null when the narration was made from a text that no longer matches. The system prompt is built per request (`libraryChatSystem`): `scopeLanguages` reads `books.language` for the scope, and the model is told to write its queries in that language — one language means every query in it, several means English first and another only when the question plainly concerns such a book or English found nothing. It used to be told the library "mixes English and Bulgarian" and ran every search twice. The catalog is re-seeded from the stored history's `data-sources` parts so follow-up turns can cite earlier ids.

**Conversations** (`lib/chats.ts`, `routes/chats.ts`, `assistant-routes.ts`): the transcript is the server's. `POST /assistant` takes `{conversationId, trigger: "submit" | "regenerate" | "approval", text, model, screen, staged, read}` — one question, never a history to be believed — verifies the conversation belongs to the profile, saves the question **before** generation, and loads the history from Postgres. A conversation's scope (`ChatScope`: library, folder, or a chosen set of books with title snapshots) is fixed at `chats.create`, which the page calls on the first question; changing sources means another chat, so an earlier answer can never appear to have searched a later selection. `searchScopeOf` turns it into what the tools may search *today* and returns null when everything chosen is gone — the route refuses and the composer is disabled with the reason; **a missing scope is never widened to the library**. **The run is the server's, not the tab's.** The response stream is teed: one branch goes to the browser, the other is read with `readUIMessageStream` and kept by `saveAnswer` with how it ended — `complete`, `stopped` or `failed` — so a partial answer survives. Closing the page ends the delivery and nothing else: the answer is finished and kept for whoever opens the conversation next. It was first built the other way round — a dropped connection *was* Stop — and a question asked from a tab that was then closed came back "Stopped before it said anything". The only things that end a run early are `chats.stop`, deleting the conversation, and the 3-minute timeout. `chats.stop` aborts and **awaits the run's end** (`stopChatRun`), so "Ask again" straight after is never refused as already answering; the run is released when the *kept* copy ends, never on `pipeUIMessageStreamToResponse`, which does not settle once the browser's response is closed — releasing on it left every stopped conversation answering 409 until a restart. While a run is going its latest partial sits in the registry (`liveAnswer`), `chats.get` appends it with `status: "streaming"` and `running: true`, and a window that did not start the run polls that once a second (`refetchInterval` in `components/assistant/AssistantPanel.tsx`) and shows it; when `running` clears the thread is remounted and seeded from what was kept. Nothing is ever retried on its own. "Ask again" is `trigger: "regenerate"` **addressed by the question's ordinal** (`question`: the page's count of questions up to the one retried, plus its `text`) and resolved by `retryQuestion`: a question refused before it was saved — no model, no tools, already answering — exists only in the page, is one past what is stored, and is saved then as the submission it never got to be; a saved one has whatever followed it dropped; any other count is refused as out of step. Unaddressed, a refused second question regenerated the *first*: its answer was deleted and the new one filed under the wrong question. An answer that did not finish still gets its `data-sources` (verified from the partial text against the run's catalog) before it is kept — the stream only carries them on a clean finish, and without them every `[c_N]` already written rendered as nothing, for good. `chats.get` returns `removedBookIds` for every book the transcript cites or chose that is gone, whatever the scope: a whole-library chat cites books it never listed as sources. The response can end a moment before the answer is kept, so anything that reads the table right after — `assistant-routes.test.ts` does — waits for the run to be released. `messagesForModel` replays a cut-short answer's words without its tool calls, because a call with no result is not a valid turn. One run per conversation (`claimChatRun`, in memory like the extract registry → 409); `deleteConversation` aborts the run before deleting the row, and `saveAnswer` looks the conversation up first, so a late answer cannot bring it back. A history that outgrows the model's context is refused by name — nothing is dropped or summarised to make room. A conversation with no title was never asked anything (its first question was refused): `chats.list` hides it and `createConversation` sweeps day-old ones. `citedBooks` accumulates the books answers actually quoted, which is what lets the sidebar's book filter offer "Quoted it" beside the default "Chose this book" — a book merely being searchable never counts.

**Frontend** (`components/assistant/`, `components/chat/`): the assistant panel is the chat — `AssistantPanel.tsx` holds the header (sources chip, History, New chat, Collapse), `AssistantThread.tsx` the transcript and composer. There is no chat page: the panel remembers its last thread per profile (`lib/assistant-prefs.ts`), the History dialog (`ChatSidebar.tsx` inside a `Modal`, with the search, the book filter and the Saved answers list) opens any other. The thread is keyed by a counter, not the id: one born in it gains an id mid-answer, and remounting there would drop the stream. `useChat` + `DefaultChatTransport` with `prepareSendMessagesRequest` sending only the new question; the conversation id, the model, the page, any staged files and any pinned text ride in each request's body. `chats.get` is read once per opening (`gcTime: 0`) because the thread seeds `useChat` from it and then owns the transcript. Message helpers (`messageText`, `messageSources`, `renderText`) are `lib/chat-message.ts`. History filtering, grouping and the scope summaries are pure in `lib/chat-history.ts`; sidebar filters are form fields in the sidebar and a chat's sources are chips in its own header, so the two never read as the same control. `SourcePicker.tsx` shows a capped list with a type-to-narrow field (title or author) — libraries run to hundreds of books. **New chat pressed inside a conversation starts on that conversation's sources** (`draftFromScope`, minus anything since removed), with the picker open and the carried books ticked first: re-picking books for every chat was a chore, while widening is one click ("clear", or Whole library). Tool calls rendered as collapsed "Searched: …" lines, `[c_N]` markers rewritten to `[n]` numbering matching the source list (`SourceList.tsx`: grouped under the book's title, each row the chapter and page plus how the passage starts — a dozen citations of one chapter were a dozen identical truncated pills). Row targets: a narrated chapter → the reader at the cue, `/books/:id/read?chapter=<index>&t=<ms>`, with the PDF kept beside it as a second button; otherwise raw or chapter with a resolvable file+page → `PdfPreviewModal` (`/pdf/:fileId#page=N`), else `/books/:bookId`; translation/variant → `/books/:bookId?variant=<key>`. `?t=` is where an unplayed chapter starts in `Reader.tsx` — the follow-scroll lands on whatever cue holds that moment.

**Indexing lifecycle**: see Job Flow #10. Backfill all existing books with `pnpm backfill:index` (skips `search_index.status === "done"` unless `--force`). Page numbers for raw text rely on `pdftotext` form feeds surviving in `book_files.raw_text` — do not strip `\f` in `lib/pdf-raw-text.ts`. Chapter chunks get per-chunk pages from `chapters.sourceBlocks` (`pageMapFromBlocks`); blockless chapters fall back to the chapter's page range.

## Chapter Detection Logic

Waterfall in `lib/marker.ts` -> `extractPdf()`:

1. **LLM-based detection** (when enabled): TOC-guided selection via `lib/toc-detect.ts` (prompts, orchestration) and `lib/toc-anchor.ts` (pure matching). Reads the printed TOC from `pdftotext -layout` text with indentation levels, asks the model which TOC tier is the chapter tier, maps printed page numbers to PDF pages from confident title matches, then places each chosen entry on the heading near its expected page and asks the model only about garbled ones. Without a usable TOC it selects from the heading catalog (selecting ~all candidates is treated as failure). Reasoning is off on every call — these are structured lookups. The crafting view shows the result as suggested starts rather than asking the model to review itself. Falls through if the API call fails or returns <2 chapters.

1b. **Numbered-chapter tier**: `pickNumberedChapterIndices()` finds Chapter N / Глава N heading sequences (digits or roman numerals), excludes ToC listing pages (≥3 chapter headings on one page), and keeps the longest increasing run of chapter numbers. Used when it finds ≥3 chapters.

2. **Heading-level heuristic** (fallback via `detectChaptersFromBlocks()`): Picks the highest heading level present (h1 → h2 → h3), splits at those heading boundaries. If no headings found, falls back to splitting every ~5000 words ("Part 1", "Part 2", etc.). If there's substantial text before the first heading (>50 words), it becomes a "Preface" chapter.

Blocks kept: Text, SectionHeader, ListItem, Handwriting.
All others dropped (PageHeader, PageFooter, Footnote, Figure, etc.).

**Important**: Marker nests its output in a subdirectory named after the PDF stem. The code handles this by searching one level deep if the JSON isn't at the top of the output directory.

**Propose with AI** (crafting view) uses `workers/propose.ts` → `lib/toc-detect.ts` with the selected `books.chapterModel`. All source PDFs in book order form one continuous book, with global page/block positions mapped back to their original file positions for review. The contents search checks the first 15 pages, extending forwards in three-page windows if contents reaches the boundary (up to 15 additional pages). Otherwise it checks the final pages in backwards-growing three-page windows (up to 15), stopping when a complete contents list is found. `pdftotext -layout` supplements saved extraction without re-running OCR. One tier call uses the contents hierarchy, printed page spans and whole-book size; it must not expand a song/story category into all its numbered pieces. All saved headings remain available for local matching; only ambiguous matches go to the model. Consecutive selected headings with no included body text between them are folded into the following chapter, preserving the grouping text at that chapter's start; genuine short chapters with body text remain separate. The parsed contents is stored as `chapterProposal.toc` with file-local source pages and the proposal appears as suggested starts with a "Use proposal" button. Proposals leave existing chapters and extraction intact until the user applies them. Proposal titles flow through `applyChapterBoundaries` to `sliceChaptersAtIndices` (with `implicitPreface: false` — text before the first start is excluded on purpose, and a preface is a start at block 0). Applying runs `lib/structure-plan.ts`: a chapter cut from the same blocks as a saved one is kept with its id, audio, edits and variants (its index-named files move with it, `lib/structure-apply.ts`), the rest are replaced, and an edited text override a cut breaks needs an explicit keep/discard decision. Applying boundaries queues `indexBook` after the new chapter rows are saved, so their search chunks and local embeddings refresh automatically. Calls use reasoning off and a 600s timeout.

**Cleanup (AI) button** (chapter modal + "Cleanup selected" toolbar batch): `workers/cleanup.ts` → `lib/cleanup.ts` sends chapter text (`customText ?? cleanText ?? rawText`, chunked via the shared `splitIntoChunks`) to the default model with a strict strip-artifacts-never-paraphrase prompt (temperature 0.3, `allowEmpty` — a 100%-garbage chunk legitimately cleans to nothing). Cleaned chunks accumulate in memory and land in `chapters.customText` in ONE final write so an interrupted run never truncates a chapter. Run state lives in the `chapters.cleanup` jsonb (`status/progress/error/runToken/createdAt/updatedAt`); `runToken` fences duplicate runs, `updatedAt` drives the 15-min stale-running guard. Batch skips chapters whose cleanup status is `done` (manual customText alone does NOT count as cleaned); re-force is per-chapter "Re-clean". Startup sweep requeues stranded pending/cleaning chapters.

## Text Normalization (`lib/normalizer.ts`)

Intentionally minimal — Kokoro handles numbers/dates/abbreviations natively. We only:
- Strip markdown syntax (bold, italic, code, links, images, headers)
- Remove reference markers ([1], [23])
- Remove bare URLs
- Rejoin hyphenated line breaks
- Collapse excess whitespace

## Kokoro TTS Details

- Model: `hexgrad/Kokoro-82M` (82M params, Apache-2.0), cached locally
- Python subprocess: `scripts/synthesize.py` called from `lib/kokoro.ts`
- Two-step synthesis: G2P + `en_tokenize` phoneme chunking upfront (exact chunk count), then `KPipeline.infer()` loop per chunk
- **510 phoneme limit**: Voice pack tensor has 510 entries (indices 0-509). `en_tokenize` can produce chunks >510 chars. `scripts/chunk_fit.py` splits such a sentence by its *text* at a clause boundary and reads each half again, so every piece keeps its tokens and word timings (cutting the phoneme string lost them, and the sync map carried the sentence twice); only a run with no space to cut at is still cut in its phonemes.
- Uses MPS (Metal Performance Shaders) on Apple Silicon; CUDA or CPU elsewhere (torch decides)
- Subprocess timeout: **3 hours** (configurable in `lib/kokoro.ts`)
- Env vars: `PYTORCH_ENABLE_MPS_FALLBACK=1`, `HF_HUB_OFFLINE=1`, Python env path via `CONDA_ENV_PATH`
- Outputs WAV at 24kHz (Kokoro's native rate); FFmpeg resamples to the pinned 44.1kHz mono during the M4A encode so chapters from any engine concat losslessly
- 54 voices across 9 languages. Best: af_heart (A), af_bella (A-), bf_emma (B-)
- Emits JSON progress per chunk to stdout: `{"type": "chunks", "total": N}` then `{"type": "progress", "chunk": 1, "totalChunks": N, "audioSeconds": 3.2}`

## Pocket TTS Details

- Model: `kyutai/pocket-tts` (100M params, CC-BY-4.0), 24kHz output, English model (`english_2026-04`)
- Python subprocess: `scripts/synthesize_pocket_tts.py`, dispatched through `synthesizeChunkedBackend` in `lib/tts.ts` like the other script backends
- **Runs in its own venv** (`.venv-pocket`, pins in `scripts/requirements-pocket.txt`): pocket-tts requires `numpy>=2`, the main env is pinned to `numpy==1.26.4` for marker/kokoro. `synthesizeChunkedBackend` takes a `pythonBin` override for exactly this; every other backend still uses `CONDA_ENV_PATH`.
- **CPU-only by design** — ~12x realtime on 2 cores, so it does not contend with Kokoro or BGE-M3 for the GPU
- No speed parameter in the model, so `voiceSupportsSpeed` returns false and the UI disables the slider
- 26 catalog voices in `lib/pocket.ts` carry the per-voice **license** metadata (two are non-commercial — see `docs/tts-licensing.md`); that column is human-researched and can't be derived. The voice **ids** are not duplicated in Python — `synthesize_pocket_tts.py` reads them from the installed package (`_ORIGINS_OF_PREDEFINED_VOICES`) so a `pocket-tts` version bump can't silently desync the two. All resolve under the English model, including the non-English speakers.
- `model.generate_audio(state, text)` defaults to `copy_state=True`, so every chunk re-forks the same voice conditioning — measured drift across chunks is an order of magnitude below the gap between two different voices. A model without that (KugelAudio, removed 2026-10-06) picked a fresh voice for every chunk.
- **Voice ids carry the language**: `pocket:<voice>` is English (the pre-language form, still valid), `pocket:<code>:<voice>` selects a checkpoint (`pocket:it:giovanni`), `pocket:custom:<uuid>` is a clone. `parsePocketVoice` in `lib/pocket.ts` is the single parser; `pocketLanguageArgs` turns it into `--language`. Clones always run on English because their state was encoded against that checkpoint.
- **Language downloads are runtime, not setup-only**: `pocketVoices.downloadLanguage` spawns `--cache-only --language X` with `HF_HUB_OFFLINE=0` — the one path allowed to reach the network — tracked in an in-memory map in `lib/pocket-languages.ts` (lost on restart, like the extract registry). Nothing is cached in the Node process, so a finished download is usable immediately with no restart. `pocketLanguageInstalled` must check **both** HF repos: with `HF_TOKEN` set the weights come from the gated `kyutai/pocket-tts`, without one from the cloning-free mirror.
- Voice cloning takes a reference audio path instead of a catalog name; the weights for it are **gated on HuggingFace** and need `HF_TOKEN` — at setup time, or later from *Your voices*, where `pocketVoices.downloadCloning` runs `--cache-only --require-cloning` (every runtime, Docker and the desktop app included, which have no setup step). The library silently falls back to non-cloning weights when the download 403s — `--require-cloning` and `resolve_voice()` turn that into an explicit error.
- `--cache-only` downloads the model plus all catalog embeddings; `scripts/setup.sh` runs it because synthesis subprocesses set `HF_HUB_OFFLINE=1`
- **Voice cloning**: `POST /upload/pocket-voice` (multipart: `file`, `name`, `consent`) → ffmpeg normalises any container to 24kHz mono int16 WAV → `--export-voice` writes a `.safetensors` state to `data/pocket-voices/`. Voice id is `pocket:custom:<uuid>`; `resolvePocketVoiceArg` (lib/pocket.ts) swaps it for the state path and errors readably if the clone was deleted, mirroring `resolveSayVoice`. Rejected without `consent=true` (Kyutai's terms) or under 8s of audio.
- Re-encoding a reference takes ~0.8s, reloading the exported state ~0.01s — which is why states are stored rather than the source audio
- **Engine capability lives in `web/src/lib/voices.ts`** (`ENGINE_PREFIXES` → `engineForVoiceId`, `voiceSupportsSpeedControl`). Runtime-discovered voices have no static entry, so a new engine MUST be added to that table or it silently defaults to "speed supported" and the UI offers a control the backend ignores.
- **Module split matters**: `lib/pocket.ts` is pure (catalog, predicates, fs probes) and is what `lib/tts.ts` imports; `lib/pocket-voices.ts` holds the ffmpeg/python subprocess work. Keep `child_process` out of `pocket.ts` — the tts dispatch tests mock `node:child_process` with only `spawn`, and a transitive `execFile` import breaks them.

## Marker PDF Extraction Details

- CLI: `marker_single` from `marker-pdf` Python package
- Output: JSON tree (Document -> Pages -> Blocks), written into `{outDir}/{pdfStem}/{pdfStem}.json`
- Each block has `bbox`/`polygon` coordinates (useful for column filtering), `section_hierarchy` for heading ancestry
- `metadata.table_of_contents` may or may not be present (some PDFs don't produce it)
- **Always `--disable_ocr`.** Marker is a layout engine here and nothing else: its OCR threw the recognition away after every run and Surya can only give line-level boxes. A scan is read by the `ocrTextLayer` step first, and marker reads the searchable copy that step wrote — `extractPdf` refuses a PDF with no text layer rather than spending half a minute discovering it
- Env vars: `TORCH_DEVICE` (mps on a Mac; cuda/cpu by capability probe elsewhere — `lib/marker.ts`), `HF_HUB_OFFLINE=1`
- Timeout: 24 hours (user cancels manually if needed)

## Logging

All worker activity is logged to both:
1. **Database** — `bookLogs` table via `appendLog(bookId, message)` in `lib/log.ts`
2. **Server console** — same `appendLog()` also prints `[book xxxxxxxx] message` to stdout

Worker task wrapper in `setup.ts` logs start/complete/fail with timing:
```
[worker] Starting synthesize (book cc693a45, ch a1b2c3d4)
[worker] Completed synthesize (book cc693a45, ch a1b2c3d4) (45.2s)
```

Workers prefix chapter-specific logs with `[Ch N]` to disambiguate parallel synthesis.

The UI has a LogViewer component that polls `books.logs` every second during processing, with a "Clear" button to wipe logs.

## Reviews gate every release

Every push to main whose tests pass is released to everyone automatically
(`.github/workflows/deploy.yml`), so **nothing reaches main unreviewed**. The review is local, not
on GitHub: no PR is needed, and nobody waits on CI before pushing. Work on a branch, run
`/code-review` locally, fix or decline each finding and report them, then **whoever did the review**
records it on the reviewed tip with `pnpm review:stamp --level <level> --summary "<outcome>"` (a git
note under `refs/notes/review`, `scripts/review.mjs`). An agent that reviewed stamps its own review,
saying in the summary that it reviewed and what it found; a stamp is never a stand-in for a review
that did not happen. Then push the branch straight to main (`git push origin HEAD:main`). The
pre-push hook refuses a push to main with an unstamped commit, and the deploy refuses to release
one; the Test workflow still runs on that push and the release waits for it to pass, so a red run
means no release rather than a blocked push. A commit added after the stamp needs another review.
Never stamp unreviewed work, never `--no-verify` a push to main, and never set `notes.rewriteRef`.
Releases are tag-only: no version commit lands on main. Details in `packages/desktop/README.md`
under *Reviews*.

## Development Commands

```bash
pnpm lint             # oxlint over packages, scripts, e2e (also runs first in CI)
pnpm lint:fix         # ...and apply what it can fix itself
pnpm typecheck        # tsc --noEmit across every package
pnpm dev              # Start server (port 3034) + web (port 3033) in parallel
pnpm dev:server       # Server only
pnpm dev:web          # Web only
pnpm db:up            # Start Postgres in Docker (port 5433)
pnpm db:down          # Stop Postgres
pnpm db:generate      # Generate Drizzle migration from schema changes
pnpm db:migrate       # Apply migrations
pnpm run setup        # Full setup (deps check, .venv + pinned Python deps, model caching, Postgres + migrations); bare `pnpm setup` hits pnpm's builtin
pnpm jobs             # Show Graphile Worker queue status (pending/running/failed)
pnpm jobs:clear       # Delete all jobs from the Graphile Worker queue
pnpm backfill:index   # Queue search indexing for all books (skips done; --force redoes)
```

## E2E Suite (`e2e/`)

Playwright suite mirroring the five intro videos' promises (`docs/use-cases.md`); see
`e2e/README.md` for conventions. Runs against an already-running dev server — there is
no Docker path and no webServer autostart.

```bash
pnpm test             # unit tests, both packages — server (vitest + template DB) and web (vitest)
pnpm e2e:smoke        # fast tier (~5s) — while developing / before committing
pnpm e2e:full         # everything incl. @slow (marker, TTS, exports) — before pushing
pnpm e2e:ui           # Playwright UI mode
```

Facts agents get wrong without reading the suite first:

- **`POST /api/books` is the fast chapter factory.** Chapters land instantly (suspended,
  default-selected, synthesizable) — use it for any spec needing chapters instead of
  waiting minutes for marker. Only UC1's extraction specs upload a real PDF.
- **The fake LLM registers at runtime, no restart.** `configModels()` in `lib/llm.ts`
  hot-reloads `DATA_DIR/llm-models.json` on mtime change; the e2e fixture writes stub
  entries there and restores the file. Never reach for `LOCAL_LLM_URL` + restart. The
  stub also scripts one OpenAI tool-calling round so agentic chat runs offline.
- **tRPC speaks plain JSON over HTTP** (no transformer): query via
  `GET /trpc/<proc>?input=<url-encoded JSON>`, mutate via POST with the raw input as
  body, unwrap `{result:{data}}`. Profile scoping is the `x-profile-id` header.
- **Test isolation is profile-per-test** with self-healing: `profiles.delete` refuses a
  non-empty profile, so teardown drains folders (which cascade their books) then root
  books first — `purgeProfile()` in `e2e/tests/helpers/trpc.ts` owns that order, and
  global setup sweeps state left by interrupted runs.

## Gotchas

- Docker Postgres is on port **5433**, not 5432. Another Docker postgres may conflict — check `docker ps`.
- **`main.ts` takes the port before it starts the workers.** The worker's first act is a sweep that treats every locked job as a dead process's; a second server booting beside a running one (the desktop app launched over `pnpm dev`, both on the same Postgres) swept the first one's running jobs and only then failed on the port. Keep `fastify.listen` ahead of `startWorker`.
- Marker output is nested in a subdirectory. Code in `lib/marker.ts` searches one level deep for the JSON.
- `metadata` field in Marker JSON output is optional — always null-check it.
- **Cancel preserves done chapters** — only sets non-done chapters to `suspended`. Synthesis cancel aborts the TTS subprocess via DB-status polling (SIGKILL). Extraction cancel (`books.cancel`, `bookFiles.cancel`) kills the marker subprocess through the in-memory registry in `lib/extract-registry.ts` — the registry is lost on a dev-server restart, but the extract worker's conditional status updates keep an orphaned marker run from overwriting the cancel.
- **`tsx watch` restarts kill Graphile Worker** but orphan Python subprocesses. In-flight jobs get re-queued on restart. Don't edit server files during long synthesis runs.
- **Graphile Worker jobs use `maxAttempts: 1`** — jobs fail once and stay failed. User retries from the UI. Use `pnpm jobs` to inspect the queue, `pnpm jobs:clear` to nuke stale jobs.
- **Book status is computed** from chapter statuses during synthesis. Only `extracting`, `assembling` come from the stored column. `computeBookStatus()` in `routes/books.ts` derives the rest.
- Python LSP errors on `scripts/synthesize.py` are expected — numpy/kokoro/soundfile are runtime deps in the Python env (`.venv`), not visible to the editor.
- Graphile Worker uses the same Postgres database. Its internal tables (`graphile_worker.*`) are managed automatically.
- **Drizzle text enums are TypeScript-only** — adding new status values (like `suspended`) doesn't require a migration since the DB column is just `text`.
- The frontend polls `books.get` every 2 seconds while processing, stops when status is `done`, `failed`, or `suspended`.
- **`skipSynthesis` is an instruction, not a state.** `workers/extract.ts` reads it once, to decide whether new chapters are born `pending` (and queued for synthesis) or `suspended`. Since 2026-09-07 every extraction path sets it (uploads send `skipSynthesis=true`; `books.retry` and `bookFiles.reExtractSelected` write it on the book and its files): extraction only produces the structure, and narration is a separate decision taken from the chapter tray after the chapters have been looked at, with the voice chosen there. Starting synthesis from extraction produced an M4B nobody asked for and spent hours before the structure was checked. The JSON API's `synthesize: true` and MCP `upload_book` with `skipSynthesis: false` are the two paths that still queue straight to TTS — both opt-in; `upload_book` defaulted to the unattended pipeline until 2026-09-25, when a book made from the assistant panel narrated all sixteen chapters the moment the extract card ran, and `extract_book` now writes `skipSynthesis` on the book before it starts, whatever the upload said.
- **Try one page** (`/books/:id/ocr?file=N`, `pages/OcrTryPage.tsx`, reached from the engine choice in `ExtractModal`) is the full-screen comparison the design brief asked for: the rendered page with Tesseract's doubted words shaded over it, a Tesseract card (a second, per-word confidence, whole-book time extrapolated from this page × page count) and a Surya card that only runs on request and streams lines over SSE with Stop. Choosing an engine writes `books.updateSettings` immediately; "Back to Extract" returns to `/books/:id?extract=1`, which `BookDetail` reads once to reopen the modal. The mock's tokens `--lowconf` / `--lowconf-ring` live in `styles.css`.
- **`ocrEngine` / `llmChapterDetection` / `language` are book properties, not app settings** — they describe the source and its text, outlive any single run, and live in `ExtractModal`, laid out in the order things happen — pictured pages read, table of contents, then language and what follows extraction — in plain words (no "Marker" on screen) rather than in the toolbar. The three re-extract buttons collapsed into one scope choice there. **Every scope is destructive** — including "chapter boundaries only", which `workers/redetect.ts` implements by deleting chapters, assemblies, documents and chapter audio — so each one names what it replaces and a tick-box gates the run whenever chapters exist.
- **`books.language` is filled from the text, never by an LLM.** `lib/detect-language.ts` (franc-min, a local trigram detector restricted to the app's own language list) sets it once when it is empty — after raw text lands, after an OCR copy lands, and in the upload dialog from the pages pdf.js sampled — and logs "Language from the text". A language someone set is never touched. An AI detector was built earlier and removed: the LLM is an addition, never a dependency, and this one needs no model.
- **Voice previews speak the voice's own language** (`PREVIEW_TEXT_BY_LANGUAGE` in `lib/tts.ts`). Reading English in a German voice sounds convincing and proves nothing — the same trap as running French text through Pocket's English model. Any new language needs a sentence there.
- **Kokoro's non-English voices take a different G2P path.** `pipeline.g2p()` returns `(phonemes, None)` for espeak-backed languages, and `en_tokenize` is English-only — feeding it that `None` is what silently broke every non-English voice until 2026-08-22. `scripts/synthesize.py` branches on `tokens is None`, and since 2026-10-06 times those chunks' words from `pred_dur` through `scripts/phoneme_words.py` (shared with Piper) — every Kokoro language with spaces between words now reads along word by word. Japanese is deliberately absent from the picker (needs a MeCab/fugashi stack + ~700 MB dictionary, and downgrades `wasabi` under spaCy); Mandarin works via the pinned `misaki[zh]` chain.
- **`HF_HUB_OFFLINE=1`** is set on all Python subprocesses. Models must be cached locally before first use. If a model is missing, the subprocess will fail (not download).
- **TTS voice licensing is mixed across engines** — some voices are non-commercial-only. Nothing binds while the project is PolyForm Noncommercial, so no voice is excluded today. Read [docs/tts-licensing.md](docs/tts-licensing.md) before relicensing, charging for hosting, or exposing an engine to paying users.
- **Voices that run in an env of their own carry `requiresEngine`** (`bg-piper:` → `piper`, `bg-bgtts:` → `bgtts`, `loudkit:` → `loudkit`). `models.engines` (`installedLocalEngines` in `lib/tts.ts`, a file-exists check, uncached) tells the picker to disable the row with the setup command, and MCP `list_voices` leaves them out. The desktop first run builds Piper's env; BgTTS's (~1.5 GB, its own torch) and loudkit's (~1.3 GB) are built on request from the voice picker's *Download and set up* card — `models.installEngine` runs `scripts/install_bgtts.sh` (the same recipe `setup.sh --bgtts` uses) or `scripts/install_loudkit.sh` with the uv at `UV_PATH`, step by step through `DownloadTracker` (`lib/engine-install.ts`). The script leaves `.installing` in the venv until it finishes, so a half-built venv never counts as installed, and since the probe is uncached the voices unlock with no restart. Docker builds neither.
- **Bulgarian-only engines read through `scripts/bg_speech.py`** — the vendored `bg_text_normalizer` (MIT, `scripts/bg_text_normalizer/README.md` lists the local changes) expands numbers, dates, currency and abbreviations before MMS, Piper and BgTTS speak. It is language-specific on purpose and lives only there; `lib/normalizer.ts` stays language-neutral. The chunk text, sync map and highlight keep the text as written.
- **Voice ids are engine-prefixed lowercase slugs** — `say:samantha`, not `say:Samantha`. `parseTtsVoice()` in `lib/tts.ts` validates per engine; the `say` slugs come from `sayVoiceSlug()` lowercasing the macOS voice name.
- **Destructive UI actions confirm via native `confirm()`** (apply chapter boundaries, delete audio, delete folder…), not custom modals. The one exception is deleting a chat conversation, which the design gave a `Modal` so it can say what goes and what stays. Browser automation dismisses these by default — Playwright needs `page.once("dialog", d => d.accept())` before the click.
- **Document exports are serialized per book** — a second `books.exportDocument` while one renders throws "Assembly already in progress". The UI can no longer walk into it: every format lives in one Export modal behind one CTA, so there is a single disabled state rather than one per button.
- **`books.list` returns `{folders, books}`**, and the root listing hides books that live inside folders — deleting "all a profile's books" via the root list alone misses foldered ones.

## Pending Task Files

The `tasks/` directory is the list — one file per idea, its first line the title. It is not repeated
here: a hand-kept copy named three of nineteen files within a month.

### Bulgarian, and why `lang` is load-bearing

Bulgarian Cyrillic is drawn differently from Russian: в, г, д, ж, з, и, к, л, п, т, ц, ш, щ, ю have
their own shapes, closer to upright italic. Source Serif 4 carries them — `cyrl` → `BGR` → `locl`,
26 substitutions — but OpenType only applies them when the text is **marked as Bulgarian**. Without
`lang="bg"` a Bulgarian reader gets Russian letterforms out of the right font, which reads as
foreign. `readingLang()` derives the code from the variant lane (a translation's key *is* its
language) or the book, and every reading surface carries it.

Two traps if you touch the `@font-face` block:

- **Weight descriptors must match across subsets.** A variable Latin face declared `font-weight:
  400 700` beside a static Cyrillic `400` makes Chrome resolve weight before `unicode-range`, pick
  the Latin face, find no Cyrillic glyph, and fall through to Georgia — silently. All four faces are
  static, one per weight per subset, for that reason.
- **Verify with pixels, not widths.** Localised forms keep identical advance widths so text does not
  reflow between languages; a width comparison will report "no difference" while the shapes differ
  completely. And set `<meta charset="utf-8">` in any test harness, or the Cyrillic arrives as
  Latin-1 mojibake and every conclusion drawn from it is wrong.