211287aed5
Phase 1 of the design-contract conformance cleanup. Add a canonical :root token block (single source of truth, mirroring design/tokens.tokens.json) to public/index.html's stylesheet and migrate the whole <style> block to var(--token); give public/auth.html its own subset :root and migrate it too. Fix all color + weight drift across every surface (stylesheet, inline styles, JS handlers, the SHARE_PAGE_CSS export): - legacy indigos #6366f1/#4f46e5/#4338ca + rgba(99,102,241) -> #818cf8/#a5b4fc/rgba(129,140,248) - blue #3b82f6 interactive buttons (incl. the whole auth screen) -> indigo - legacy darks #0a0e17/#0b1120/#020617/#121828/#1f2942 -> the surface ladder - #f5f9ff -> #f1f5f9, #312e81 -> #1e293b, weights 650->600 / 680->700 The meta theme-color stays a literal #0a0e1a. Verified: 144 tests pass, both pages serve 200, all var() references resolve. Phase 2 (var-ifying the long-tail inline styles, snapping off-scale font/radius) is in ROADMAP.md.
154 lines
18 KiB
Markdown
154 lines
18 KiB
Markdown
# 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/<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`.
|
|
- **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 `<audio>` node (`replaceWith`, src-matched) and restores `.chunks-scroll` scrollTop, so a background re-render (e.g. the ~60s relay-credit poll) doesn't stop playback or bounce the reader to the top. YouTube minimize toggles the `.results-left.minimized` CSS class **in place** — never `render()`, because creating the YT iframe inside a `display:none` container wedges the IFrame API (black frame, needs reload); `ensureYtMounted()` + a `!state.videoMinimized` guard on `needsMount` keep the player from ever being built hidden, and `initPodcastPlayer()` is idempotent (`dataset.inited`). Don't reintroduce a full `render()` on minimize or drop these preservation steps.
|
|
- **URL parsing is duplicated server + client — edit both in lockstep.** `extractVideoId` (and the sibling classifiers `isChannelUrl` / `isPodcastUrl`) live in BOTH `server/util.js` and `public/index.html` with the same logic; the server copy is the one that actually rejects a submit (`server/index.js` → "Invalid YouTube URL"). When you teach one a new URL form, teach the other or the frontend preview and server will disagree. `extractVideoId` accepts `/watch?v=`, `youtu.be/`, `/embed/`, `/v/`, `/live/`, `/shorts/`, and a bare 11-char id; trailing `?si=…` tracking params are fine (the regex stops after the id). Tests: `server/test/util.test.js`.
|
|
|
|
### 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.159** + relay **0.2.126**. Tests: `cd server && npm test` → **144 pass**.
|
|
|
|
**Done & live:** self-serve Pro/Max purchase (Bitcoin inline-Lightning + Zaprite card, prepaid, relay owns tier/expiry), core-decoupling, per-tenant subscriptions, expiry-reminder emails (`POST /api/admin/reminders/run {test_email}`), **opt-in Daily Digest** (0.2.158, `b4fa5d7`): off-by-default daily email of a user's last ~24h of library recaps, each synthesized via `/relay/analyze` (operator-absorbed); `daily-digest.js` scan at `SEND_HOUR=8`, per-user watermark dedup, public tokenized unsubscribe, admin trigger `POST /api/admin/digest/run`; and **YouTube `/live/` + `/shorts/` URL support** (0.2.159, `cb961cd`): `extractVideoId` now accepts those forms (was rejecting them as "Invalid YouTube URL"). Plans in `docs/*-plan.md`.
|
|
|
|
**Design system (2026-06-16, committed `1741fb1` + a follow-up commit, NOT yet deployed):** the `design/` contract was extracted from the as-built UI (`design/DESIGN.md` + `design/tokens.tokens.json`; see the **Design** line near the top). Then **Phase 1 of the conformance cleanup landed**: a canonical `:root` token block is now the single source of truth in `public/index.html`'s `<style>`, the whole stylesheet + `public/auth.html` are migrated to `var(--token)`, and all color/weight drift was fixed across every surface (auth's blue accent → indigo, legacy darks → the ladder, etc.). Verified locally (144 tests pass, both pages serve 200, all `var()` resolve) but **not installed/deployed** — reaches recaps.cc when the box serves the new `public/` files. Phase 2 (var-ifying the long-tail inline styles, snapping off-scale font/radius) is queued in `ROADMAP.md`.
|
|
|
|
**Only loose end:** the Daily Digest's relay-synthesis + SMTP path can't be exercised off-box, so it's installed but **not yet smoke-tested** — that's operator action #5 below. Everything else (schema/upgrade, scheduler boot, unsubscribe flow) is verified.
|
|
|
|
**Pending operator actions:**
|
|
1. **Verify the mobile can't-scroll-to-top fix on the iPad** — UNVERIFIED in 0.2.157 (iOS-layout-specific, not reproducible off-device); send a screen recording if it persists. Inbox item kept open + annotated.
|
|
2. (optional) Rotate the still-live Gemini key in AI Studio, then `rm /Users/macpro/Projects/recap-keyleak-purge-backup.bundle`.
|
|
3. Real-world cloud tests: first Bitcoin purchase; enable Zaprite cards (relay "Set Zaprite Connection" + webhook); eyeball a reminder email.
|
|
4. If recaps.cc ever gains a CDN/LB hop, set `RECAP_TRUSTED_PROXY_HOPS` or the trial-cap bypass reopens.
|
|
5. **Smoke-test the Daily Digest (0.2.158):** (a) `POST /api/admin/digest/run {test_email}` to eyeball the sample render; (b) toggle it on in Settings, add a recap, then `POST /api/admin/digest/run` (no body) to force a real scan — confirms relay synthesis + SMTP send + the unsubscribe link end-to-end. Needs System-SMTP configured.
|
|
|
|
**Backlog** in `ROADMAP.md`: eval **P2** known-debt (SSE error-string scrub, credit-debit TOCTOU, multi-tenant gemini-key bypass, `GET /api/history` perf, dependency CVEs, integration tests, doc drift) + **P3** cleanup, and standing decisions (Zaprite recurring, "take Recaps home" broken for relay-tier users, cloud paid-only, no CI lint/type-check).
|