# 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`. ## 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. - **`safeFilename()` already exists** for any user-content → on-disk path. Use it; don't roll your own. - **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`. - **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. ### Client-side contract with the relay The full client-side relay contract — env vars, the `/relay/*` endpoint list, `X-Recap-*` header directions, and the file map — lives in **`docs/guides/relay-client.md`**. Read it before editing `server/providers/relay.js`, `relay-capabilities.js`, `relay-default.js`, `billing-routes.js`, `credits-purchase.js`, `subscription-reminders.js`, or the relay env-var resolution in `config.js`. Canonical endpoint shapes are in `../recap-relay/AGENTS.md`. ### Cross-repo changes (sibling: `../recap-relay`) This repo and the relay (`../recap-relay`) share a live client/server contract — the `/relay/*` endpoints, the `X-Recap-*` headers, request/response shapes, and tier/credit semantics. **Before finishing any change that touches that boundary, check whether `../recap-relay` needs a matching change.** If you add/rename/remove a relay call, alter a payload shape or header, or shift tier/credit/billing behavior, update the relay side too — and reflect it in BOTH repos' `AGENTS.md` (the contract docs) and `ROADMAP.md` (if it's staged work). Purely local changes (UI, library handling, packaging) don't need this. When unsure whether a change is contract-affecting, assume it is and check. ## Always - **Bump the version before `make install`.** StartOS dedupes sideloads by version string — installing the same version twice silently no-ops. Use `make bump` or edit `startos/versions/index.ts` + add a `vN.ts` file. Applies to EVERY iteration, even a one-line edit. - **Add new version files to BOTH the import block AND the `other:` list** in `startos/versions/index.ts`, and update `current:` to the new version constant. - **Ask before `make deploy` / `make redeploy`.** These push to the Start9 community registry — public-facing, attribution-tracked, irreversible from your laptop. `make install` is the safe iteration loop. - **Verify mDNS resolution before blaming it** when `make install` fails. Substitute the operator's actual StartOS hostname (the `host:` field in `~/.startos/config.yaml`) and run `curl -sk "https://${STARTOS_HOST}/rpc/v1" -X POST -d '{}' -H 'Content-Type: application/json'` — if that reaches the box but `start-cli` doesn't on the same target, it's almost certainly **macOS Local Network privacy** blocking the third-party `start-cli` binary (Apple's `curl`/`ping` are exempt, so the box looks reachable). Tell: `node -e` TCP-connect to `:443` also gives `EHOSTUNREACH` while `curl` gets 200. Fix: System Settings → Privacy & Security → Local Network → enable the Claude app (restart Claude Code if it doesn't take). This often flips off after a Claude Code update. Details: memory `feedback_macos_local_network_install`. - **Reference env-var names, never values.** Secrets live in `.env` / `.deploy.env` (both gitignored). Examples for new vars belong in `.deploy.env.example`. ## Never - **No "Co-Authored-By"** trailers on commits, no mention of "Claude" in source files, comments, or commit messages. Commits are authored by the user. - **Never claim `make install` succeeded without verifying it.** Confirm the `make` exit code is 0 AND the new version actually shows on the box (`start-cli package list`) — not just that the command ran (a `tail`/pipe can mask a non-zero exit). Installs DO work from this agent's shell now; the old "`start-cli` is blocked by the sandbox" framing was a misdiagnosis — it was macOS Local Network privacy, which is fixable (see the Always "verify mDNS" rule). - **Never `make deploy` to the registry** without explicit per-action approval, even if a prior session ran one. - **Never edit `startos/versions/.ts` for a version that's already been built and is being tested.** Add a new version file instead — operators may already have the prior `.s9pk` cached. - **Don't add the relay's `internal-meetings` feature here.** That lives in the sibling `../recap-relay` repo. This repo is the client/library; the relay does diarization + clustering + meeting analysis. - **Don't push to GitHub by default.** The configured remote is self-hosted Gitea unless the user says otherwise. - **Don't pull `cookies.txt` into commits** — it's an operational yt-dlp artifact, not source. - **Never modify `~/.startos/config.yaml`** without authorization (contains host credentials). ## Adjacent repo - `../recap-relay` — the operator-side credit-metered service this client talks to. Owns Gemini/Parakeet/Sortformer routing, diarization, internal-meeting analysis, and the operator dashboard. See `../recap-relay/AGENTS.md` for its endpoint shapes, build/deploy rules, and roadmap. Reference it but do not change it from inside this repo. ## Current state **Live on the operator's StartOS box** (app **0.2.155** + relay **0.2.124**, installed 2026-06-09): - **Self-serve purchase COMPLETE — all 5 phases** (`docs/self-serve-purchase-plan.md`). Signed-in cloud users buy Pro/Max themselves: "Pay with Bitcoin" renders an inline Lightning QR on-screen (no redirect); "Pay by card" mints a Zaprite one-time order (the card link shows only when the operator has configured Zaprite). Prepaid 30-day periods; the relay owns tier + expiry; both settle webhooks land at `extendUserTier`. Expiry-reminder emails (7d / 1d / lapsed) ride the existing System SMTP; operator test trigger: `POST /api/admin/reminders/run` with `{test_email}`. Tier cards show the real per-period credit allotment from the relay quota config (this box: Max = 120, Pro = 50). - **Core-decoupling live** (relay owns cloud tier; `docs/core-decoupling-plan.md`) and **per-tenant subscriptions live** (`docs/per-tenant-subscriptions-plan.md`). - The Bitcoin pill matches the standard purple; the relay-side **internal-meeting re-polish fix** (re-attributes topic summaries to the operator's corrected speaker names) shipped this session. **Pending real-world tests (operator):** 1. First on-device Bitcoin purchase — sign in as a Core tenant → Upgrade → Pay with Bitcoin → pay the inline invoice → badge flips. 2. Enable cards — run the relay's "Set Zaprite Connection" action (API key) + register the Zaprite webhook at `https:///relay/zaprite/webhook`. 3. Eyeball a reminder email via the test trigger above. ### Evaluation work queue (P0/P1) — from the 2026-06-14 full-eval (`EVALUATION.md`) Five of seven items FIXED + tested (2026-06-15, reviewer-checked); the leaked-key purge awaits operator confirmation and the registry blockers are deferred. - **[P0] ✅ FIXED — arbitrary file write in `POST /api/library/import`.** `safeFilename()` is now exported from `history.js` and validates each import key (`server/library.js`); a `../../` key is skipped, not written outside the scope dir. Tests in `test/history.test.js`. *(Adjacent P2 still open: array-form import + `PUT /api/history/move` write unvalidated IDs into `_meta.json` — no file-path escape, and read-time `safeFilename` guards the load. See Known debt.)* - **[P0] ✅ FIXED — SSRF in podcast download.** `downloadPodcastAudio` (`server/audio.js`) rejects non-HTTP(S) schemes and blocks IP-literal AND DNS-resolved private/link-local/loopback/reserved/multicast/translation-prefix targets (closing the DNS-rebinding window), caps + resolves redirects. Tests in `test/audio.test.js`. *(Response size/time cap still deferred — ROADMAP P3.)* - **[P0] ⏳ Leaked Gemini key in git history** (`git show d5046a0:.env`). Operator to rotate in Google AI Studio (recommended, not strictly required since the repo was never shared); git-history purge via `git filter-repo --path .env --invert-paths` + force-push to Gitea is queued and runs on the operator's go-ahead. - **[P1] ✅ FIXED — ESM `require("crypto")`.** Replaced with a top-level `import { randomBytes }` in `server/license-purchase.js`. - **[P1] ✅ FIXED — global `currentFreeJob` lock serialized the whole cloud.** Now skipped in multi-mode (`server/index.js`: `const isFree = req.recapMode !== "multi" && isFreeUser()`); per-tenant credit metering is the control there. - **[P1] ✅ FIXED — `X-Forwarded-For` trial-cap bypass.** `app.set("trust proxy", …)` from `RECAP_TRUSTED_PROXY_HOPS` (default `1`) + `getClientIp` now returns `req.ip` (`server/index.js`, `server/anon-trial.js`). Tests in `test/anon-trial.test.js`. **Watch:** if the cloud ever gains a CDN/LB hop, bump `RECAP_TRUSTED_PROXY_HOPS` or the bypass reopens. - *(StartOS registry-submission blockers — deferred by decision 2026-06-15; moved to `ROADMAP.md`. They never affected `make install`.)* ### Known debt (P2) — track; not release-blocking for self-host - **Operator-internal strings leak to cloud users at the SSE error boundary** (Parakeet/Gemma/CUDA/LAN IPs) — no scrub exists, violating the scrub contract above. `server/index.js:3432,3003,4246` + `providers/relay.js:135-144`. (Sharp edge: `index.js:3419` *detects* these strings, then forwards them anyway.) - **Credit over-spend TOCTOU on licensed installs** — N parallel requests pass the `total>0` check before any blind `debitOne` lands. Make check+debit atomic (reserve up front, refund on failure). `index.js:2497-2550` vs `:3158,:4197`. - **Multi-mode tenant can spend the operator's server Gemini key** via `transcriptionProvider:"gemini"` + empty key (bypasses relay metering) — `providers/index.js:104-114`. Refuse the operator-key fallback for non-admin tenants. - **`GET /api/history` parses every full session file** (transcript+summary, MB each) just to list ~8 metadata fields — cache them into `_meta.json` on save. `history.js:418-437`. - **Dependency CVEs** — nodemailer 6.10.1 (high; low practical reach here), ws/qs/express/protobufjs (moderate). `npm audit fix` (nodemailer is a major bump). - **No tests on the riskiest files** (`/api/process` gating, `relay.js`, `tenant-auth.js`, billing) — every code P0/P1 above lives here, and no agent could run the real summarize→save→debit path end-to-end (no key/credits). Add an integration test as the regression net before/with the fixes. - **Smaller hardening:** unsanitized IDs persisted to `_meta.json` (array-form import + `history/move`); `PUT /api/history/meta` accepts arbitrary JSON shapes with no schema; `index.js` is 4351 lines mixing routing/pipeline/yt-dlp/SSE. - **Doc drift (high-value):** AGENTS.md credit-gate order omits the "paid cloud user" bypass state (`:77` vs `index.js:2464-2472`); operator-facing `startos-registry/.../INSTRUCTIONS.md` + `assets/ABOUT.md` are stale Gemini-first (relay is the default provider). Lower-severity doc nits are deferred in `ROADMAP.md`. **Known issues / open decisions** (details + next actions in `ROADMAP.md`): - **Zaprite recurring isn't built** — Zaprite's API only does one-time orders. Card = prepaid until that's confirmed with Zaprite. - **"Take Recaps home" is likely broken** for relay-tier users (no `keysat_license` after decoupling). - **Cloud still has a free signed-in tier** (the simplification plan's "cloud paid-only" is unbuilt). - No CI lint / type-check (unchanged).