Files
recap/AGENTS.md
T
Keysat 693bb981ff Fix mobile/UX bug cluster: video minimize, audio interrupt, scroll reset, redundant box
Four fixes in public/index.html, all reported against recaps.cc on mobile:

- Video minimize no longer shows a black frame on expand. toggleVideoMinimize()
  used to call render(), rebuilding the YouTube iframe inside the display:none
  minimized container, which wedged the IFrame API. Minimize now toggles the
  .results-left.minimized class in place; a !videoMinimized guard on render()'s
  needsMount plus a new ensureYtMounted() (called from the expand paths) keep the
  player from ever being created in a hidden container.

- Background processing no longer interrupts podcast audio or resets the
  transcript scroll. The ~60s relay-credit poll calls render(), which rebuilt the
  <audio> element and chunks-scroll. render() now preserves the live <audio> node
  across the innerHTML swap (replaceWith when the src matches) and restores
  chunks-scroll scrollTop; initPodcastPlayer() is idempotent so the preserved node
  doesn't get duplicate listeners.

- Removed the redundant centered "Processing..." box; the staged pizza-tracker
  breadcrumb already covers that window.

- Added -webkit-overflow-scrolling/overscroll-behavior to .chunks-scroll for the
  mobile can't-scroll-to-top report (best-effort, needs on-device verification).

Ships as 0.2.157. Reviewer pass clean; inline JS syntax checked with node --check.
2026-06-15 17:38:32 -05:00

146 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<file>.test.js` |
| Run one test by name | `cd server && node --test --test-reporter=spec --test-name-pattern='<substring>' test/<file>.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/<vN>.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`.
- **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 `<box-ip>: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/<v>.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.157** installed 2026-06-15 + relay **0.2.124**). Note: `recaps.cc` is served from this same box via Start9 Pages + StartTunnel, so a `make install` here updates the public cloud site automatically — there is no separate cloud deploy.
- **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.
**This session (2026-06-15):** ran the 2026-06-14 full-eval (`EVALUATION.md`) and cleared its 3 P0 + 4 P1 findings. Five code fixes shipped with tests + a reviewer pass — library-import file-write, podcast SSRF, ESM `require`, multi-mode concurrency lock, and the `X-Forwarded-For` trial-cap bypass — committed + pushed (`d0e9842`), 119 tests pass. The leaked Gemini key was purged from all git history and force-pushed; **the rewrite re-hashed every commit** (solo private repo, no other clones — harmless). Registry-submission blockers deferred.
**Also this session — iOS sign-in flake fixed (shipped as 0.2.156, built + installed + verified on the box):** an iPad user hit a spurious "network error" on the first tap of *Send sign-in link*, with the second tap succeeding. Root cause is the classic iOS Safari behavior of dispatching a `POST` onto a pooled keep-alive socket the server/proxy has already closed; unlike a GET it isn't transparently re-sent, so it surfaces as a transport `TypeError`. The existing single 500 ms auto-retry was too quick — it reused the same dead socket. Both sign-in entry points (`public/auth.html` `postWithRetry`, `public/index.html` `fetchWithRetry`) now retry 3× with growing backoff (0 → +400 ms → +1.6 s) to outlast Safari evicting the socket. Frontend-only, no server change; the embedded JS has no test harness. Mitigation not cure — if it ever recurs, confirm via box logs whether `/auth/request-link` is hit once (request never arrived → my diagnosis) or twice (failure on the response path → different bug) before widening the backoff.
**Also this session — mobile/UX bug cluster from the inbox (shipped as 0.2.157, built + installed + verified; reviewer pass clean, no blockers):** four `public/index.html` fixes. (1) **Video minimize → black/needs-refresh:** `toggleVideoMinimize()` called `render()`, which rebuilt the YouTube `#yt-player` iframe inside the `display:none` minimized container and wedged the IFrame API. Now minimize toggles the `.results-left.minimized` CSS class in place (iframe stays mounted); a `!state.videoMinimized` guard on render's `needsMount` + a new `ensureYtMounted()` (called from the expand paths) ensure the player is never created in a hidden container. (2) **Background processing reset transcript scroll + killed podcast audio:** root cause was the ~60s relay-credit poll calling `render()`, which rebuilt the `<audio id="podcast-audio">` and `.chunks-scroll`. `render()` now preserves the live `<audio>` node across the innerHTML swap (`replaceWith` when the src matches — exploits the spec's async "pause on disconnect") and restores `.chunks-scroll` scrollTop; `initPodcastPlayer()` is idempotent (`dataset.inited`) so the preserved node doesn't double its listeners. (3) **Redundant centered "Processing…" box** removed (pizza-tracker breadcrumb already covers that window). (4) **Mobile can't-scroll-to-top:** added `-webkit-overflow-scrolling:touch` + `overscroll-behavior:contain` to `.chunks-scroll` — **best-effort, UNVERIFIED**; it's iOS-Safari-layout-specific and couldn't be reproduced off-device, so it needs an on-iPad check (and a screen recording if it persists). Inline JS syntax verified via `node --check` on the extracted script.
**Pending operator actions:**
1. (optional) Rotate the Gemini key in AI Studio — the purge removed it from the repo, but the key itself is still live. Then delete the pre-purge backup: `rm /Users/macpro/Projects/recap-keyleak-purge-backup.bundle` (it contains the old key).
2. Real-world cloud tests: first on-device Bitcoin purchase (Core tenant → Upgrade → Pay with Bitcoin → badge flips); enable cards (relay "Set Zaprite Connection" + webhook `https://<relay-host>/relay/zaprite/webhook`); eyeball a reminder email (`POST /api/admin/reminders/run` `{test_email}`).
3. If recaps.cc ever gains a CDN/LB hop in front of the app, set `RECAP_TRUSTED_PROXY_HOPS` accordingly or the trial-cap bypass reopens.
**Next dev steps + open decisions** live in `ROADMAP.md`: the eval's **P2 known-debt** list (SSE error-string scrub, credit-debit TOCTOU, `GET /api/history` perf, dependency CVEs, integration tests for the untested hot paths, doc drift) and **P3** hardening/cleanup, plus the standing decisions (Zaprite recurring, "take Recaps home" broken for relay-tier users, cloud paid-only, no CI lint/type-check). Tests: `cd server && npm test` → **119 pass**.