# AGENTS.md — Recaps YouTube + podcast summarizer + library, served as a single-page app from a Node.js backend. Ships as a StartOS `.s9pk` (single-mode self-host) and as the public `recaps.cc` cloud (multi-mode tenants). > **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for > items tagged `(recap)` and surface them before proposing next steps; triage with `/triage`. > **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and > `design/tokens.tokens.json` and conform to them. Accent is indigo `#818cf8`; purple > `#a855f7` is premium-only. The same tokens govern three surfaces that must stay in sync: > the main `public/index.html` stylesheet, its `SHARE_PAGE_CSS` share-export string, and > `public/auth.html`. ## Stack - **Server**: Node.js (`type: module`, ES modules). The dev box currently runs `v25.6.1`; container runtime is whatever the `Dockerfile` pins — check before assuming. - **Frontend**: One file, `public/index.html`, with vanilla JS embedded (no framework, no bundler). Render is a render-string-into-`innerHTML` loop driven by a module-scoped `state` object. - **DB**: SQLite via `better-sqlite3`. Multi-mode only; single-mode keeps everything on the filesystem. - **Packaging**: `@start9labs/start-sdk` under `startos/` — version graph at `startos/versions/index.ts`. - **Deps of note**: `@anthropic-ai/sdk`, `@google/genai`, `openai`, `nodemailer`, `express`, `@keysat/licensing-client` (vendored at `vendor/keysat-licensing-client`). ## Commands Run from repo root unless noted. | Action | Command | |---|---| | Dev server (single-mode default) | `cd server && npm run dev` | | Prod server | `cd server && npm start` | | Run all tests | `cd server && npm test` | | Run one test file | `cd server && node --test --test-reporter=spec test/.test.js` | | Run one test by name | `cd server && node --test --test-reporter=spec --test-name-pattern='' test/.test.js` | | Build `.s9pk` (x86) | `make x86` | | Bump version (interactive) | `make bump` | | Install to local StartOS | `make install` *(see Always/Never — bump first; the binary is `start-cli` under the hood)* | | Type-check (StartOS TS) | `npm run check` *(repo root; runs `tsc --noEmit` over `startos/**/*.ts`. The `server/` is plain JS and is not type-checked.)* | | Format (StartOS TS) | `npm run prettier` *(repo root; `prettier --write startos`. There is **no** ESLint/linter — `server/` JS is untooled. Many `startos/versions/*.ts` are currently unformatted.)* | Mode is selected at boot via the `RECAP_MODE` env var: `single` (default) or `multi`. Other runtime env var of note: `RECAP_TRUSTED_PROXY_HOPS` (default `1`) — how many trusted reverse proxies sit in front of the app, so the anonymous-trial per-IP cap reads the real client IP from `X-Forwarded-For` (set `0` if the app is directly internet-facing, `2`+ behind a CDN/LB; setting it too high re-opens the trial-cap bypass). ## Directory layout ``` server/ index.js main HTTP + SSE entry; mounts every route providers/ relay.js, gemini.js, openai.js, anthropic.js, ollama.js, openai-compatible.js, whisper.js — each implements the provider interface in providers/index.js anon-trial.js multi-mode trial-cookie minting + IP cap tenant-credits.js multi-mode signed-in-tenant credit pool history.js per-scope library save/load + REST handlers config.js StartOS config snapshot + server-side API-key resolver db.js SQLite schema apply + getDb() handle (multi-mode only) billing-routes.js multi-mode self-serve purchase: /api/billing/{plans,buy,status}; Bitcoin (BTCPay inline Lightning) + card (Zaprite) rails subscription-reminders.js daily expiry-reminder scan → sendMail (multi-mode) smtp.js StartOS System-SMTP transport (magic links + reminders) test/ node --test files public/ index.html the whole single-page app, ~10k lines vanilla JS auth.html standalone magic-link landing page (multi-mode) startos/ manifest/ StartOS package manifest versions/.ts one file per shipped version + index.ts version graph actions/ operator-facing StartOS Actions docs/ design notes; treat as in-progress, not authoritative bin/bump-version.sh used by `make bump` and `make deploy` vendor/keysat-licensing-client/ local-link Keysat SDK ``` ## Conventions - **Plain language over jargon**, especially for git / packaging / dev-tooling steps. - **Don't be sycophantic.** Push back when something doesn't add up. - **Honest reports.** A failing test/build is a failure, even if pre-existing or unrelated. Don't fold it into a "success" summary. - **Diff size matches change scope.** Small reviewable diffs, not sweeping rewrites. - **Comments explain WHY, not what.** No narrating self-evident code. No referencing tasks/PRs/callers in source — that rots. - **Match the file's own style** over any default of your own. The frontend's vanilla-JS shape is intentional; don't reach for a framework. - **Write the test alongside the change** when the area already has tests (`server/test/*.test.js`). The repo uses the built-in `node --test`. - **Plans persist in `docs/`** when scoped + named (e.g., `docs/per-tenant-subscriptions-plan.md`); ephemeral planning lives in conversation/tasks, not Markdown files. ### Conventions for this codebase specifically - **Relay is the modern default provider.** The legacy "must have a Gemini API key configured" gate is dead — server-side callers should pick `relay` when configured, fall back to `gemini` only if a local key exists, otherwise surface a clear user-facing error. The frontend stores the choice in localStorage; the server can't read it. - **Sanitize operator-internal language at error boundaries.** Strings like "Spark Control", "parakeet", "vLLM", LAN IPs, `*.local` URLs come from the sibling relay and must not reach cloud users. - **Multi-mode credit gates fire BEFORE the pipeline.** See `/api/process` for the order — admin → license → free tenant → trial → anonymous-mint. Don't reorder without reading the comment block. - **Trial IP cap is per-IP for IPv4, per-/64 prefix for IPv6.** Dual-stack home networks would otherwise bypass it via privacy-extension address rotation. - **Client IP comes from `req.ip`, never a raw `X-Forwarded-For` entry.** Express `trust proxy` is set in `index.js` from `RECAP_TRUSTED_PROXY_HOPS` (default 1); `getClientIp` (`anon-trial.js`) returns `req.ip`. Trusting raw `XFF[0]` let clients spoof the trial-cap IP — don't reintroduce it. - **`safeFilename()` is exported from `history.js`** — import and use it for any user-content → on-disk path; don't roll your own. It validates against `/^[A-Za-z0-9_-]+$/` and throws on traversal/separators (the library-import file-write hole was a missing call). - **The relay owns cloud Pro/Max tier + expiry** (core-decoupling; `docs/core-decoupling-plan.md`). In multi-mode, paid status is `users.tier` — cached from the relay, keyed by the Recaps user-id — NOT a per-user Keysat license. Don't gate cloud paid features by `keysat_license`; the license only matters for self-hosted "take it home" portability. Cloud requests carry `X-Recap-User-Id` + the operator key; server-to-server tier reads/writes go through `providers/relay.js`. - **Server-side background relay calls that the operator should eat go through `resolveProviderOpts("relay", { req: null })`** → the operator install identity, the *same* relay credit pool free signed-in users' summaries already draw from (`providers/index.js` `pickRelayIdentity`). That's the "operator-absorbed" lane — no comped system user-id, no operator action. To bill a specific user instead, pass their cloud identity (`{cloud:true, userId, operatorKey}`). The Daily Digest synthesis (`daily-digest.js`) is the reference use. - **Self-serve purchase has two rails, both prepaid (no auto-renew yet).** Bitcoin = a BTCPay invoice rendered as an INLINE Lightning QR on-screen — the relay returns the BOLT11 server-to-server, so the buyer never loads BTCPay (replicate the buy-credits inline flow; do NOT redirect to a hosted checkout). Card = a Zaprite one-time hosted order (Zaprite's API has no recurring — see ROADMAP). Both settle webhooks land on the relay (it owns subscription expiry); the frontend just polls `/api/billing/status`. Expiry reminders go out via the existing System-SMTP transport (`smtp.js`): the relay enumerates who's expiring (`GET /relay/expiring-subscriptions`), Recaps maps user-id → email and sends. - **Tier credit allotments are operator-config-driven, never hardcoded.** The cards' "N relay credits each period" comes from the relay's tier-quota config (`credits_per_period` on `/relay/tier-plans`); `null` → "Unlimited". Don't bake a number or "Unlimited" into the UI. - **`recaps.cc` IS the operator's StartOS box**, served via Start9 Pages + StartTunnel. So `make install` (after a version bump) updates the public cloud site automatically — there is no separate cloud deploy step. A frontend-only change reaches recaps.cc as soon as the box serves the new `public/` files. - **`render()` rebuilds the whole view via `innerHTML` — preserve live media + scroll across it.** It re-attaches the live podcast `