# 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 `/.venv/bin`, created by `scripts/setup.sh`), `POCKET_ENV_PATH` (Pocket TTS Python env bin dir; default `/.venv-pocket/bin`), `PIPER_ENV_PATH` / `BGTTS_ENV_PATH` (the two CPU Bulgarian engines' env bin dirs; default `/.venv-piper/bin` and `/.venv-bgtts/bin` — BgTTS is opt-in, `pnpm run setup --bgtts`), `LOUDKIT_ENV_PATH` (loudkit's env bin dir; default `/.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 `/.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 `/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 `/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"; ``` **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 ``, 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 `:` selects a checkpoint (`pocket:it:giovanni`), `pocket:custom:` 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:`; `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 --summary ""` (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/?input=`, 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 `` in any test harness, or the Cyrillic arrives as Latin-1 mojibake and every conclusion drawn from it is wrong.