Unreleased
Added
- Cold email — Moxxie capabilities, in-chat HITL, and rollback/revert (2026-05-29). Extended the cold-email capability registry so Moxxie can diagnose and operate outreach through existing `discoverAgentCapabilities` / `invokeAgentCapability` — no new chat tools. Reads (risk none): `listSegments`, `getFunnel`, `deliverabilityReport`, `getListHealth`, `getReplies`, `readSequence`. Low-risk commands: `pauseCampaign`, `adjustCadence`, `revertCadence`. High-risk (founder approval): `updateSequence`, `createVariant`, `runPipeline`, `revertSequence`, `deleteEmail`. High-risk proposals now surface Approve/Reject cards in Moxxie chat (`invocation-proposal-card.tsx`) wired to `POST /api/admin/cmo/invocations/[id]` and pending banner via `/api/admin/cmo/invocations/pending`. Mutating commands persist `result.rollback` (`rollback-types.ts`, `rollback-queries.ts`) so operators can revert sequence copy, cadence caps, or delete mistaken variants from the Agents panel. Service layer `agent-capabilities.ts`; Mautic client adds list/get/update/clone/unpublish/publish/delete email helpers; policy and rollback unit tests.
- CMO knowledge — admin edit/delete, GitHub read fallback, and full-doc proposals (2026-05-29). Knowledge corpus preview no longer fails when a doc exists on GitHub `main` but not on prod disk: `readRepoFileAsync` falls back to `fetchFileFromMain`. Admin knowledge drawer gained inline Edit / Save / Delete (`knowledge-file-drawer.tsx`) backed by PUT/DELETE on `/api/admin/cmo/knowledge/file`. Moxxie can propose `proposeDocReplace` (full overwrite) and `proposeDocDelete` (git + corpus removal; `docs/founding/` blocked) via `doc-edit.ts` / `commands.ts`. Restored `project_docs/cmo/H2_2026_REVENUE_SPINE.md` from `origin/main`.
- CMO chat — founder-approved doc creation/indexing into the knowledge corpus (2026-06-30). Moxxie can now propose `proposeDocCreate` from chat to create a new markdown strategy/planning artifact under `project_docs`, `docs`, or `content`; the founder reviews the full file preview, approves the card, and the server commits the new file to GitHub and indexes that exact content into the CMO knowledge base immediately. She can also propose `proposeDocIndex` for specific existing markdown docs that are missing from the corpus, avoiding a broad full reindex when only a few reports need embeddings. Existing `proposeDocEdit` remains the find-and-replace path for current docs.
- CMO brain — H1 reports promoted into Moxxie's H2 planning context (2026-06-30). Moxxie's curated knowledge prompt now leads with the H1 analytics stock-take, H1 SEO review, and H1 needs-your-call close-out before evergreen strategy docs, so daily briefs and Ask Moxxie planning start from the latest H1 evidence. Added an H2 planning artifact at `project_docs/cmo/H2_2026_CMO_ANALYSIS_AND_PLAN.md`, including the intended H2 CMO focus, July KPI scorecard, per-agent directives, and a caveat that provider-backed reindex/brief regeneration requires an approved production run.
- CMO analytics — H1 2026 SEO/GTM stock-take with evidence bundle (2026-06-30). Added `project_docs/cmo/H1_2026_ANALYTICS_STOCKTAKE.md`, a source-labeled H1 readout for 2026-01-01 through 2026-06-30 combining configured GA4/GSC API data, Chrome-authenticated GA4/GSC/Mixpanel screenshots, production first-party funnel aggregates, and the current GTM/SEO/free-tools strategy docs. Evidence screenshots, extracted text, and the sanitized Google API snapshot live under `project_docs/cmo/assets/`. Key conclusion: H1 built indexation and content supply, but H2 needs rank/CTR gains, free-tool completion fixes, and cleaner acquisition attribution.
- Support thread — edit founder drafts inline before sending (2026-06-29). Founder drafts in `/admin/support` now have an Edit button alongside Send / Discard. Clicking it swaps the draft body into an autosizing textarea with Save / Cancel; saving PATCHes the new body via `/api/admin/support/messages/[messageId]` (CAS-guarded against already-sent rows via `isNotNull(draftedAt)` — a stale UI can never rewrite a message the recipient has already seen). New `updateDraftBody()` helper in `store.ts`.
Fixed
- Moxxie chat — multi-card HITL, scroll lock, and collapsible approval cards (2026-07-01). When one assistant turn had two or more approval cards (e.g. `setAgentDirective` + `proposeDocReplace`), approving the first card disabled Approve on the rest and could start phantom streaming that locked scroll at the bottom until Stop was clicked. Root cause: global `busy` gated all HITL buttons during `useChat` streaming, and `sendAutomaticallyWhen` fired as soon as _any_ write tool resolved while siblings were still pending. Fix: `chat-hitl.ts` gates auto-continue until all write tools and capability proposals in the last turn are resolved; approval buttons disable only while _that_ card's verdict is in flight; stick-to-bottom scroll respects manual scroll-up; completed cards collapse to a one-line verdict via `approval-card-shell.tsx`. Unit tests in `chat-hitl.test.ts`.
- EL key pool — failover cap dynamic, split-brain across module instances, and webhook stage label (2026-06-29). Three bugs surfaced together when reisha@gmail.com's retranscribes routed to bridge instead of ElevenLabs despite el_3 having quota. (1) Failover cap was hardcoded to 2 while the pool had 3 keys, so el_3 never got tried (tx #265: kind=el_quota_exhausted, exhausted=el_1, failover attempts=2, el_3 idle the whole time). Replaced the constant in `webhooks/elevenlabs/route.ts` with `getMaxFailoverRetries() = max(2, pool.length)`. (2) Split-brain across module instances: Next.js bundles server actions and route handlers into separate module copies, so the webhook handler's pool and the worker's pool had independent in-memory `_preferredAlias` / `keyStates` — webhook marked el_1 exhausted in its copy, persisted to disk; worker's copy never re-read, kept picking el_1. Three retries, all on el_1. Fix in `elevenlabs-key-pool.ts`: drop the `_hydrated` once-per-process boolean; re-hydrate on every `selectKey` via `fs.statSync` mtime check (sub-ms when unchanged); persist `preferredAlias: null` explicitly so a "clear preferred" write actually clears it in the other instance. (3) Stage label drift: the webhook success path REPLACED `metadata` without re-stamping `stage: 'completed'`, and the reconciler's audit-write in `stuck-transcription-cleanup.ts` spread the PRE-recovery metadata snapshot on top of the post-recovery write — reviving `stage: 'elevenlabs_webhook_pending'` after a recovered tx had landed, so admin UI showed "completed" badge + stale subtext. Webhook success + `transcript-recovery.ts` now stamp `stage: 'completed'`; reconciler re-fetches current metadata before merging the audit fields. 7 affected rows backfilled via `jsonb_set`.
- Sage — bump maxOutputTokens 800 → 40 000 for kimi-k2.6:cloud reasoning headroom (2026-06-29). Sage replies were coming back empty with `finishReason='stop'` because kimi-k2.6 reasons internally over Ollama Cloud's OpenAI-compatible endpoint and those tokens count against `maxOutputTokens` but get stripped by the AI SDK ("Non-OpenAI reasoning parts not supported"). At 800 the model burned the whole budget on hidden reasoning before producing visible text. `think:false` / `reasoning_effort` are no-ops on `/v1/chat/completions` for kimi (already probed). 40k is 20% of kimi-k2.6's 200k context — comfortable for any realistic Sage thread. `support/assistant/route.ts`.
- Meeting ops analytics + provider seam cleanup (2026-06-25). Finished the next Phase 2 hardening slice on top of the existing admin controls. Repair activity is now first-class operational data instead of ad hoc metadata fragments: added a shared helper in `meeting-repair-events.ts` that appends capped `repairEvents` trails and emits logged product events for admin repair requests, reconciliations, drift, upstream cancellations, and sync failures. Those repair trails are now stamped across the live meeting surfaces — connected calendar accounts (`meeting-admin-service.ts`), imported calendar events and sync runs (`meeting-calendar-sync.ts`), scheduled-bot overrides and provider-ingest failures (`meeting-bot-service.ts`). `/admin/meetings` now consumes that data as an ops surface, not just a button panel: new query helpers in `admin-queries.ts` expose latest repair snippets per account/import/session plus summary cards for repairs in the last 24h, repair failures, drifted imports, upstream cancellations, and successful reconciliations; the page renders those timelines directly in the connected-calendar, import-monitor, and session views. Attendee stays swappable more cleanly now: `meeting-bot-provider.ts` gained explicit provider capability flags and richer parsed-webhook mapping fields so the orchestration layer stops relying on implicit Attendee behavior. Coverage: added a focused unit test for repair-event metadata behavior in `meeting-repair-events.test.ts` and extended the DB-backed Phase 2 hardening suite in `meeting-phase2-hardening.test.ts` to assert repair stamping during admin dispatch, reconciliation, cancellation, and duplicate terminal webhook handling. Docs updated: `docs/features/MEETING_CAPTURE.md`, `docs/plans/MEETING_CAPTURE_ARCHITECTURE_PLAN.md`, and `docs/README.md`.
- Meeting repair event audit + registry-based provider resolution (2026-06-25). Extended the same Phase 2 work from “latest repair snippets” into a real historical ops surface and finished the next layer of provider decoupling. Ops now have a dedicated long-window audit page at `/admin/analytics/events/meetings`: it reads `event_log` directly for meeting repair events, supports 24h / 7d / 30d / 90d windows, exposes filters for event name, result, scope, provider, and user, and summarizes failures, drift, upstream cancellations, and reconciliations without requiring raw log spelunking. The existing `/admin/meetings` and general event-audit page now link into it. Provider resolution is no longer singleton-bound: `meeting-bot-provider.ts` now exposes a small provider registry, configured-provider discovery, default-provider selection, and webhook-provider resolution; request validation, immediate dispatch, scheduled dispatch, ingest, webhook handling, and watchdog eviction were updated to resolve providers by name through that seam in `meeting-bot-request.ts`, `meeting-bot-service.ts`, `meeting-bot/route.ts`, `webhooks/meeting-bot/route.ts`, and `meeting-bot-watchdog-job.ts`. This keeps Attendee as the active adapter today while making a second provider or dual-run path a bounded follow-up instead of a cross-cutting refactor. Coverage: added `meeting-bot-provider.test.ts` and kept the request/admin route suites green. Verification note: the DB-backed hardening suite could not be rerun in this session because sandbox escalation for local Postgres access was unavailable; the sandboxed fallback failed with local socket `EPERM`, which is environmental rather than a code regression.
- Phase 2 completion sprint — admin meeting controls, repair entrypoints, Attendee hardening, dashboard clarity, and docs refresh (2026-06-24). Finished the next operational layer on top of the existing meeting-agent/calendar backbone without coupling the provider seam. Admin controls now live on `/admin/meetings`: connected-calendar rows gained `Sync now`, auto-schedule toggle, disconnect, and import drill-down actions; import/session rows gained targeted `Dispatch now`, `Cancel scheduled bot`, `View session`, and `View transcript` actions; and a header-level `Run due-bot sweep now` repair trigger was added. Admin-only APIs now sit under `/api/admin/meetings/*` with thin route handlers and service helpers in `meeting-admin-service.ts` for account mutation, sync enqueue, dispatch-now, cancel, and due-sweep enqueue. Calendar repair semantics were formalized: account-level `Sync now` now reuses the normal connected-calendar sync pipeline as the reconciliation entrypoint, while targeted dispatch/cancel operations stay inside the existing scheduler/service seam. Attendee hardening now persists richer raw provider state and triage metadata, surfaces missing-recording failures explicitly, and keeps duplicate terminal lifecycle events idempotent around ingest start inside `meeting-bot-service.ts`. Dashboard clarity improved in `meetings-page-client.tsx` and `meetings-calendar.tsx`: attached participants see shared/reused session ownership clearly, and calendar health is translated into user-facing states like `meeting changed`, `cancelled upstream`, and `updated from calendar`. Docs drift repaired: updated `docs/features/MEETING_CAPTURE.md`, `docs/plans/MEETING_CAPTURE_ARCHITECTURE_PLAN.md`, and `docs/README.md`. Coverage: added route-level admin handler tests in `meeting-admin-routes.test.ts`, expanded OAuth fail-closed coverage in `meeting-calendar-oauth.test.ts`, and extended the Phase 2 hardening suite in `meeting-phase2-hardening.test.ts` for participant visibility, admin overrides, and provider-ingest failure cases.
- Meeting stack hardening — unified bot policy, calendar reconciliation, and admin ops visibility (2026-06-24). Closed the main Phase 2 reliability gaps without changing the provider/capture architecture. Scheduling policy is now consistent across entrypoints: both `POST /api/meetings/bot` and `POST /api/meeting-bots/schedule` run through a shared validator/service guard in `meeting-bot-request.ts`, enforcing the same contract for auth, Studio+ `meetingBot` entitlement, supported/configured provider, valid `https` meeting URL, and consent attestation. Shared workspace visibility is complete: participant-attached users can now see reused meeting sessions/bots through `meeting-bot-service.ts`, while ownership/billing semantics remain with the creator. Calendar sync now reconciles lifecycle changes instead of schedule-once-only: `meeting-calendar-sync.ts` can cancel mutable scheduled imports when events disappear, move scheduled join times when events shift, update pre-dispatch meeting URLs, mark unschedulable events cancelled/skipped, and preserve audit drift metadata once a bot/session is no longer safely mutable. OAuth fails closed: `meeting-calendar-oauth.ts` no longer falls back to a hardcoded state secret; the Google/Outlook connect routes return a clean configuration error if secret material is missing. Admin/ops visibility: `/admin/meetings` now shows connected-calendar health, stale heartbeat counts, import sync errors, reconciliation/drift/cancellation counters, issue filters, unhealthy-only filtering, and direct jumps from import monitor rows into the affected session view, powered by new queries in `admin-queries.ts`. Coverage: route/unit tests in `meeting-bot-request.test.ts`, OAuth fail-closed coverage in `meeting-calendar-oauth.test.ts`, and DB-backed reconciliation/visibility tests in `meeting-phase2-hardening.test.ts`.
- ElevenLabs multi-key pool — quota-aware rotation with paid bridge fallback (2026-06-24). Supports 1–N ElevenLabs API keys via a new `ELEVENLABS_KEY_POOL` env var (format: `alias:apiKey:webhookSecret,...`). Keys are round-robin rotated with in-memory state tracking: on `quota_exceeded` / `insufficient_credits` / `exceeds your quota` errors, the key is marked exhausted for 24h and the next key is tried; on auth errors (non-quota 401), the key is disabled for 1h. If ALL keys in the pool are exhausted, paid users fall back to the self-hosted STT bridge with `priorityClass: 'paid'` and diarization enabled (not silently dropped). Each transcription stores `metadata.elKeyAlias` for audit trail — recovery and webhook verification resolve back to the correct account. Webhook multi-secret verification: the `/api/webhooks/elevenlabs` route loops through all pool keys' webhook secrets for HMAC-SHA256 verification (microseconds per key; falls back to legacy `ELEVENLABS_WEBHOOK_SECRET` if no pool). Recovery key resolution: `transcript-recovery.ts` reads `metadata.elKeyAlias`, resolves the key via `getKeyByAlias()`, and falls back to trying all pool keys if the alias is missing (handles key rotation after the job was submitted). Backward compatible: if `ELEVENLABS_KEY_POOL` is not set, the existing `ELEVENLABS_API_KEY` + `ELEVENLABS_WEBHOOK_SECRET` single-key setup works unchanged. Incident context: tx #193 (paid Studio user) spent 51 min on the slow bridge because the single ElevenLabs Starter plan (90K credits) was exhausted with 339 credits remaining; two Starter accounts ($10/mo total, 120K credits) now provide redundancy cheaper than one Creator upgrade ($22/mo, 121K credits). Files: `elevenlabs-key-pool.ts` (new), `env.ts`, `elevenlabs-stt.ts`, `stt.ts`, `stt-elevenlabs-webhook.ts`, `transcription-worker.ts`, `submit-bridge-transcription.ts`, `bridge-stt-client.ts`, `transcript-recovery.ts`, `webhooks/elevenlabs/route.ts`.
Security
- v1 API response audit — strip internal fields from all public endpoints (2026-06-18). Audited every `/api/v1/*` route handler for information leakage and applied a systematic cleanup. Critical: `GET /api/v1/webhooks/endpoints` and `PATCH /api/v1/webhooks/endpoints/:id` were returning the full DB row including `signingSecret` in plaintext on every call — now stripped; `signingSecret` is only present on the `POST` (create) response. `POST /api/v1/webhooks/test` and `POST /api/v1/webhooks/deliveries/:id/retry` were echoing back the endpoint's `signingSecret` and internal `retryScheduleMs` schedule — stripped, returning only `ok`, `responseStatus`, `responseBody`, `errorMessage`, `signature`. High: `GET /api/v1/jobs/:id`, `POST /api/v1/jobs/:id/retry`, and the internal `transcription.completed` webhook dispatch payload all leaked `provider: "openai" | "elevenlabs" | "self-hosted"` revealing the vendor stack — removed. `GET /api/v1/transcriptions/:id` was returning the raw Drizzle row (full `db.select()` spread), exposing `rawApiResponse`, `processingCost`, `webhookJobId`, `webhookReceivedAt`, `metadata`, `chunked`, `chunkCount`, `storageKey`, `fileUrl`, `providerModel`, `userId`, `deletedAt` — now returns an explicit allowlist: `id`, `status`, `fileName`, `fileSize`, `fileType`, `language`, `duration`, `transcriptText`, `diarizationEnabled`, `speakerCount`, `createdAt`, `updatedAt`, `completedAt`. `POST /api/v1/transcriptions/:id/translate` leaked `provider` and `cached` — removed. Medium: `GET /api/v1/usage` `recentRequests` included `provider` per log row — removed from the `getApiUsageSummary` select. SDK types (`packages/sdk`) updated throughout to match; `WisprsWebhookEndpoint.signingSecret` is now optional (present only on create). Dashboard API settings page "Provider" column removed (field no longer returned). Admin pages are unaffected — they query the DB directly and retain full `provider` visibility.
Added
- Public status page — 90-day uptime strips with hover cards + keyboard nav (2026-06-23). `/status` now matches industry status-page UX (Claude-style): each component row shows a 90-day bar chart with 90 days ago — {avg}% uptime — Today footer (centered percentage on a connecting rule), hover/focus day cards (date, outage summary, Related incidents from `event_log`), and full keyboard navigation (roving tabindex, arrows, Home/End, Page Up/Down ±7 days, `aria-live` announcements). Data layer maps 90-day derived incidents onto calendar days per surface (`status-summary.ts`); interactive UI in `uptime-strip.tsx`. Third-party dependencies get compact strips + the same footer layout.
- Support portal — message recall, delivery ticks, reply language, quota-aware triage (2026-06-23). Closes the Danny case (Urdu user, 41-min file vs 30 min/day free cap, AI wrongly blaming concurrent uploads). Recall: founder messages can be recalled anytime (`POST /api/admin/support/messages/:messageId/recall`) — sets `recalledAt`/`recalledBy`, hidden from user-facing routes via `listMessagesForUser`, muted placeholder in admin (`support-thread.tsx`). Delivery tracking: `emailResendId`, `deliveredAt`, `readAt`, `clickedAt` on `support_messages` (migration `0056`); Resend outbound stores id + deep-link CTA (`?c=` for click tracking); `/api/webhooks/resend` updates support rows; admin ticks (sent/delivered/read/clicked) + 30s poll. AI language + notes: `reply-language.ts` suggests draft language (last transcription → IP geo → `en`); collapsible AI instructions (language select + custom notes) on inbox composer and `message-user-dialog.tsx`; passed through process/draft APIs into `agent.ts`. Quota gates: pending/processing minutes count toward daily cap (`getPendingTranscriptionMinutesToday`, `projectFreeDailyMinutes`); `durationSeconds <= 0` blocked for free/trial; structured failure metadata (`gate`, `durationMinutes`, daily used/remaining); `createTranscription` pre-check when duration known; tests in `gate.test.ts`. Sage gained `getDailyQuota` read tool + richer failed-transcription fields. Agent docs: `project_docs/support/AGENTS.md`.
- Meetings calendar UI + both calendar OAuth providers live on prod (2026-06-22). Shipped the user-facing calendar surface and provisioned the real OAuth apps the Phase-2 backbone (below) was waiting on. UI: added a Schedule / Calendar tab toggle to `/dashboard/meetings` (blends into the flat-nav design — no new route/nav item). The Calendar tab (`meetings-calendar.tsx`) renders a month grid of scheduled/active bots as status-toned pills (native `Date`, no new dependency) plus a grouped agenda with cancel/transcript actions; clicking a day pre-fills the Schedule form for that date. A connect-a-calendar panel (`connect-calendar-panel.tsx`) lists connected accounts and starts Google/Outlook OAuth, degrading gracefully (upgrade/coming-soon notice) when a provider isn't configured. Backend: one range query `listMeetingBotsForUserInRange()` + `GET /api/meeting-bots/calendar` for month navigation; the page server-component pre-fetches the current month + connected accounts. (commit `6b37565`) OAuth provisioning (prod): Google Calendar OAuth app created in GCP project `wisprs` (External + Testing, redirect `…/google/callback`, `calendar.readonly`) — verified connected end-to-end. Microsoft/Outlook Entra app "Wisprs Calendar" registered in the getwisprs.com business-M365 tenant (multi-tenant + personal accounts, redirect `…/outlook/callback`, Graph `Calendars.Read` + `User.Read`). `CALENDAR_ACCOUNT_KEY`, `GOOGLE_CALENDAR_CLIENT_ID/SECRET`, `MICROSOFT_CALENDAR_CLIENT_ID/SECRET` set on prod (`MICROSOFT_CALENDAR_TENANT_ID` defaults to `common`). See docs/features/MEETING_CAPTURE.md → "Calendar OAuth — Production State". Note: Google is Testing-mode (test-user allowlist) and the Microsoft multitenant app needs publisher verification before _other_ orgs' users can consent — same-tenant + personal accounts work today.
Fixed
- Upload auto-start + orphan recovery cadence + admin status labels (2026-06-23). Fixes the #191 failure mode where paid uploads sat `pending` for ~19 minutes because `/api/upload` and the chunked upload worker never called `processTranscription` (user had to click Start on the detail page). Both paths now await `enqueueTranscriptionAfterUpload` after file save; `/api/upload` also scopes storage keys to `user-{id}/` (`route.ts`). Orphan path B cron tightened from 30 min to 10 min (`stuck-transcription-cleanup.ts`). Admin + transcript list show Awaiting start vs Queued via `isAwaitingStart` / `transcriptionBadge`; admin filter `?awaitingStart=1`. Docs: `RECONCILERS.md`.
- Calendar OAuth callbacks crashed / redirected to localhost (2026-06-22). Two prod-only bugs surfaced on the first real Google connect. (1) The callback 500'd while storing tokens because `CALENDAR_ACCOUNT_KEY` (the token-at-rest AES key) was unset on prod — the OAuth exchange succeeded but `connectGoogleCalendarAccount`/`…Outlook…` threw `CALENDAR_ACCOUNT_KEY must be configured`; fixed by setting the env var. (2) After storing, the post-connect redirect sent users to `http://localhost:2222/dashboard/meetings?calendar=google_connected` (`ERR_CONNECTION_REFUSED`) because both `google/callback` and `outlook/callback` built the redirect from `request.nextUrl.origin`, which behind nginx resolves to the internal upstream bind. Switched both to `serverPublicOrigin()` (loopback-rejecting, hard-defaults `https://wisprs.co`). (commit `cda681a`)
- Meeting capture Phase 2 — connected calendars, OAuth callbacks, sync worker, and idempotent event import ledger (2026-06-20). Built the missing calendar-autopilot backbone on top of the existing Attendee meeting-bot path. Added first-class connected account persistence in `connected_calendar_accounts` with encrypted token-at-rest storage (`connected-calendar-account-crypto.ts`) plus authenticated CRUD routes at `/api/meeting-calendar/accounts`. Added real Google and Microsoft calendar OAuth connect/callback entry points (`/api/meeting-calendar/google/*`, `/api/meeting-calendar/outlook/*`) and shared OAuth state/config helpers in `meeting-calendar-oauth.ts`. Added a periodic connected-calendar sync pipeline in `meeting-calendar-sync.ts` + `meeting-calendar-sync-job.ts` that refreshes tokens, fetches upcoming events from Google Calendar and Microsoft Graph, normalizes them through the existing meeting-calendar parser, and routes schedulable events into the existing bot scheduler/service layer. Added the idempotent import ledger `meeting_calendar_event_imports` with migration `0051_meeting_calendar_event_imports.sql` so repeated sync cycles do not create duplicate bot/session rows. Also fixed an operational gap in Phase 2 by actually registering the pre-existing scheduled meeting-bot worker in `start-workers.ts`. Env contract extended with Google/Microsoft calendar OAuth secrets, `CALENDAR_ACCOUNT_KEY`, and calendar sync cron/lookahead settings in `env.ts`. Meeting-capture docs and architecture plan were updated to reflect the real current state.
- The Floor — live 3D agent office visualization for the CMO portal (2026-06-20). A new admin-only page at `/admin/cmo/floor` that renders the 32 marketing agents as blocky voxel workers in a cozy isometric office (claw3d-style), where each worker's pose + name-tag status dot encodes live telemetry and clicking one opens the existing capability panel — a control surface, not a screensaver. Built with React Three Fiber (three.js): `three`, `@react-three/fiber` v9, `@react-three/drei` v10, `@react-three/postprocessing`, lazy-loaded via `next/dynamic({ ssr: false })` so three.js never touches SSR or other admin route bundles (installed with `npm install --legacy-peer-deps` due to a pre-existing fumadocs↔next peer conflict). Floor plan: a 3×3 grid of glass-partitioned rooms — five department rooms (desks in a grid, sub-agents next to their parent) plus Boardroom, Lounge, Kitchen, and Reception furnished with props — pendant lights, corner plants, and framed wall art for warmth (scene.tsx, room.tsx, ghost.tsx). Data spine (pure composition, no new domain logic): `getFloorState()` merges `getEngineState()`, the agent roster, proposed high-risk invocations (the "needs-you" signal), and per-department activity into one `FloorSnapshot`, served by `GET /api/admin/cmo/floor` (admin-gated) and polled every 5s alongside the existing `/api/admin/cmo/ticker` feed (3s) for transient success-pops and pipeline flow batons (use-floor-data.ts, flow-particles.tsx). State machine (state.ts `resolveGhostState`, priority-ordered): a worker types while running, slumps when blocked, hops + auto-labels amber when an approval is waiting, dims when its department is quiet, sits benched/translucent when coming-online; the status tag shows a dot at rest and the full name on hover/select or whenever the agent is blocked/needs attention (declutters the floor at desk density). The 32-agent cast (per-agent color + role) lives in `AGENT_VISUALS`. Interaction mirrors the existing governed flow — clicking a worker opens agent-panel.tsx which routes into the same `POST /api/admin/cmo/agents/[id]/invoke`, `/api/admin/cmo/invocations/[id]` approve/reject, and `runAgentNow` server action (HIGH-risk `cold-email.runPipeline` founder-approval gate unchanged). Performance designed in: frameloop pauses when the tab is hidden and drops to a low-FPS ambient when the engine is calm (the cost-saver doubles as the "floor is sleeping" look), DPR capped at 1.5, `prefers-reduced-motion` respected (floor-hud.tsx overlay HUD). Nav entry added under Moxxie CMO (admin-sidebar.tsx). v1 workers are procedural box characters; sculpted GLTF models + legged walking are a deliberate later upgrade against the same renderer-agnostic data spine.
- Apify Store — 4 actors live + critical bug fixes + durationSeconds API field (2026-06-20). Launched four Wisprs actors on the Apify Store under the `toshiusklay` namespace: `wisprs-transcription` (base, build 1.0.6), `wisprs-podcast-show-notes` (1.0.6), `wisprs-youtube-repurposer` (1.0.6), and `wisprs-social-media-transcriber` (1.0.4). Critical actor fixes (actors had never successfully completed a real transcription): (1) API envelope not unwrapped — `wisprsGet`/`wisprsPost` returned the raw `{ success, data }` wrapper; every downstream field was `undefined` because code read `submitted.jobId`, `job.transcriptionId`, etc. off the envelope; fixed by returning `payload.data`. (2) Field name mismatches — API returns `id` (number), not `jobId`; there is no `transcriptionId` field (job `id` IS the transcription ID); fixed in all 4 actor `main.ts` files and `WisprsJob` interface. (3) Poll loop never exits — `job.status` was reading from the envelope (always `undefined`); fixed by envelope unwrap. (4) Repurpose mode `show_notes` (underscore) → `show-notes` (hyphen) — the API rejected underscore with 400; fixed in Input interfaces, `input_schema.json` enums, and docs. (5) TypeScript errors from `fetchExports`/`fetchRepurpose` parameter types `string` → `number`. Additional improvements: graceful exit on missing API key (exit code 0, pushes a dataset error row — required for Apify automated QA); QA-safe prefill URL `?v=jNQXAC9IVRw` (18-second video, completes in <90s); `maxPollSeconds` default corrected to 240 (Apify QA has a 5-minute hard timeout); `blog` added to repurpose modes; `apiKey` input field added for BYO-key users; audio-minute PPE billing restored via batched `Actor.charge({ count: durationMinutes })`. Backend fix: added `durationSeconds: transcription.duration ?? null` to `GET /api/v1/jobs/:id` response (src/app/api/v1/jobs/[id]/route.ts) — this field was available on the DB record but not exposed, blocking per-minute billing; needs deploy before audio-minute charges fire. Docs: rewrote `packages/marketplace/apify/README.md` with actor registry table, full code-state checklist, API contract reference (envelope pattern, exact response shapes), and official Apify docs links; updated `STRATEGY.md` current status; corrected all four store-facing `README.md` files (field names, actor URLs, `repurposed_show-notes` key, example URLs, `language` vs `detectedLanguage`). PPE events configured: `transcription-submitted` $0.005, `audio-minute` $0.015, `repurpose-result` $0.075, `short-video-transcribed` $0.001. Pending (operator): remove "under maintenance" flag from Publication tab on base actor `yslIrM00tN6dYrWH9`; deploy backend `durationSeconds` change to enable per-minute billing.
- RapidAPI Hub distribution — complete OpenAPI spec, CORS, proxy-secret middleware (2026-06-18). Closes the gaps for listing Wisprs on RapidAPI Hub. `packages/marketplace/rapidapi/openapi-proxy.yaml` rewritten from a 5-line stub to a full publication-ready spec: 7 RapidAPI-facing endpoints (`POST /transcriptions`, `GET /jobs/{id}`, `POST /jobs/{id}/retry`, `GET /transcriptions/{id}`, `GET /transcriptions/{id}/export`, `POST /transcriptions/{id}/repurpose`, `GET /library/search`) pointing at `https://wisprs.co/api/v1`, with complete request/response schemas, inline examples, and rich descriptions for each mode/format. Auth strategy: User Auth / Bearer token — developers enter their own Wisprs API key in the RapidAPI dashboard, which forwards it as `Authorization: Bearer ...`; no proxy layer needed, Wisprs billing and metering apply natively. Fixed canonical spec server URL: `api.wisprs.com` → `wisprs.co` (public/api/openapi.yaml). Added CORS support in middleware.ts: OPTIONS preflight returns `204` with `Access-Control-Allow-Headers` for `X-RapidAPI-Key`/`X-RapidAPI-Host`/`X-RapidAPI-Proxy-Secret`; responses to `*.hub.rapidapi.com` origins carry `Access-Control-Allow-Origin`. Added `X-RapidAPI-Proxy-Secret` validation: when Rapid Runtime is in use, the middleware checks the header against `RAPIDAPI_PROXY_SECRET` (new optional env var in env.ts) and returns `403` on mismatch — prevents direct abuse bypassing RapidAPI billing. Operator next steps (dashboard only, no code): create provider account → import `openapi-proxy.yaml` → Gateway tab: point to `https://wisprs.co/api/v1`, copy Proxy Secret → set `RAPIDAPI_PROXY_SECRET` on prod → Auth: User Auth / Bearer → Monetize: Free (100 req/mo) / Basic $9 (1k) / Pro $29 (5k) / Enterprise $99 (unlimited).
- Meeting capture — Chrome extension + durable chunk ledger + admin monitor (2026-06-18). Phase 1 of the meeting ingestion system. A Manifest V3 Chrome extension (browser/extension/) records tab + microphone audio in the browser, splits it into 5-second WebM chunks, queues them in IndexedDB, and uploads each chunk to the server with session-scoped upload tokens — surviving network blips and tab crashes via idempotent `(session, track, sequence, sha256)` dedup. Server side: `POST /api/meeting-sessions` creates a session and issues an upload token; `/api/meeting-sessions/:id/chunks` accepts chunks (idempotent, sha256-verified); `/heartbeat` keeps the session alive; `/finalize` triggers assembly and enqueues the recording through the existing transcription pipeline. Bot-controlled recording stubs added at `POST /api/meeting-bots/schedule` + `/api/webhooks/meeting-bot` for Phase 2 (Recall.ai / native SDK integration). DB: four new tables — `meeting_sources`, `meeting_sessions`, `recording_chunks`, `bot_instances` — in migrations 0043–0045 (schema.ts). Dashboard: completed meeting transcripts appear under a `Meetings/<date>` folder with a meeting badge (transcription-card.tsx, transcriptions-table.tsx); admin monitor at `/admin/analytics/meeting-capture` (admin/meeting-capture.ts). Dev harness at `/dev/meeting-capture-test`; 10/10 unit tests in `meeting-capture.test.ts`. Middleware extended to pass upload-token Bearer requests through to route-level validation (fixes the extension 401 on chunk upload). Architecture plan + API reference in docs/features/MEETING_CAPTURE.md + docs/api/MEETING_CAPTURE_API.md.
- API settings — usage chart + per-key filter + pagination (2026-06-18). `getApiSettingsData` in actions.ts now accepts `{ tab, keyId, statusFilter, page }` options and returns `chartData` from a new `getApiUsageChartData` query (usage.ts), enabling the API usage sparkline chart (api-usage-chart.tsx) and paginated/filtered request log (50 rows per page, filter by key and status). Developer docs prose CSS extracted into `globals.css` (`prose-developer` layer) covering h1–h3, p, ul/ol, code, table, and screenshot elements; light-mode media query removed (app is dark-only).
- Multi-language code tabs across all developer doc pages (2026-06-18). Replaced single-language code samples with tabbed TypeScript | Python | cURL switchers on all 7 applicable developer doc pages (`quickstart`, `authentication`, `jobs`, `webhooks`, `export`, `repurpose`, `search`). Infrastructure: new `MultiLangCodeBlock` async server component (doc-code.tsx) runs Shiki on all tab variants server-side via `Promise.all`; `MultiLangCodeBlockClient` (multi-lang-code-block.tsx) toggles tabs client-side via the `hidden` attribute (no re-highlight flicker) and persists the selected language to `localStorage` key `wisprs_docs_lang` so switching tabs on one page carries through to all others. JSON response blocks remain single-language. Python samples use snake_case SDK mirrors (`client.transcriptions.create(...)` → `client.transcriptions.create(...)`, `transcript_text`, `min_score`, etc.).
- Agent-ready developer docs — llms.txt, per-page markdown endpoints, AI toolbar, MCP doc tools (2026-06-18). Made all 11 developer docs pages consumable by AI agents and tools. `src/lib/developer/docs-content.ts` is the single content source: 11 doc pages as clean markdown (no internal library names). `GET /developer/docs/raw/{slug}` returns each page as `text/markdown` so Claude, ChatGPT, and crawlers can fetch it directly. `/llms.txt` updated with a new `## Developer API Documentation` section listing all 11 raw doc URLs with descriptions. Each docs page now shows a "Copy page | ▾" split button: the left half copies the page's markdown to clipboard (fetched live from the raw endpoint); the chevron opens a dropdown with View as Markdown, Open in Claude, and Open in ChatGPT — the latter two use the same prompt pattern as Next.js docs (`Read from this URL: <raw-url> and explain it to me.`) so the AI fetches live context. `/developer(.*)` added to the middleware public-route allowlist (docs no longer auth-gated). Two new MCP tools added (`list_documentation` + `get_documentation`) bringing the server to 14 tools total — agents can now discover and read any Wisprs doc without leaving the tool environment.
- Developer docs portal — 9 guide pages with Shiki code blocks + embedded UI screenshots (2026-06-18). Shipped the full `/developer/docs` portal with a sidebar layout and nine production-ready guide pages: quickstart, authentication & keys, submit a job, webhooks, export formats, AI repurposing, semantic search, TypeScript SDK, Python SDK, and MCP server (src/app/developer/docs/). All code samples use a new `DocCodeBlock` async server component that runs Shiki `codeToHtml` with the vitesse-dark theme, a traffic-light header, and a copy button — language tags for `bash`, `typescript`, `python`, `json`. Fixed per-line rendering by injecting `style="display:block"` + `white-space:pre` directly via Shiki's `line` and `code` transformer hooks (bypasses CSS cascade issues in Next.js App Router where global CSS `.line { display: block }` rules don't propagate to Shiki-generated `dangerouslySetInnerHTML` content). JSON samples are properly pretty-printed with 2-space indentation throughout. Nine UI screenshots captured from the live app (`api-settings`, `api-usage`, `api-webhooks`, `dashboard-inbox`, `library-search`, `library-search-typed`, `transcription-detail`, `transcription-export`, `transcription-formats`) and embedded inline with `DocScreenshot` at contextually relevant sections: API settings → quickstart §1; dashboard inbox → jobs §submit; transcription detail → jobs §retrieve; export panel → export §formats; library search → search §endpoint; webhook form → webhooks §register. Screenshots mirrored to `marketing/images/` for the marketing team.
- Versioned API v1 platform foundation + control plane + SDK scaffold (2026-06-17). Shipped the first real commercial API surface under `/api/v1/*` instead of audience-labeled routes: media ingest, chunked upload init/complete, transcription create/get, export, summary, chapters, quotes, job status, usage, and signed webhook test. Added scope-aware API key auth and Redis-backed per-key rate limiting (api-keys.ts, api-rate-limit.ts, v1.ts); per-key usage + request logging, webhook endpoint/delivery storage, and request audit tables in schema.ts with generated migration 0042_wooden_stranger.sql applied; completion callbacks now dispatch from both the sync worker and ElevenLabs webhook completion paths (transcription-worker.ts, webhooks/elevenlabs/route.ts). Added an initial API settings control plane at `/dashboard/settings/api` with API key creation/revocation, usage visibility, webhook endpoint registration, and delivery history; a starter OpenAPI spec at public/api/openapi.yaml; and a TypeScript client scaffold at `packages/sdk` (`@wisprs/sdk`) that wraps the new `/api/v1` contract.
- API v1 expansion + webhook operations + distribution scaffolds (2026-06-17). Extended the public platform from the initial foundation into a broader developer surface: added transcript repurpose and library search routes under `/api/v1/transcriptions/:id/repurpose` and `/api/v1/library/search`, richer webhook endpoint and delivery management under `/api/v1/webhooks/endpoints`, `/api/v1/webhooks/endpoints/:id`, `/api/v1/webhooks/deliveries`, and retry support at `/api/v1/webhooks/deliveries/:id/retry`. Delivery logic now retries across a defined backoff schedule and exposes endpoint pause/resume plus retry controls in the dashboard API settings UI (webhooks.ts, actions.ts, page.tsx). Polished the TypeScript SDK for packaging and publishing with build config, exports, job waiting, library search, webhook helpers, and generic repurpose methods in `packages/sdk`; added marketplace wrapper scaffolds for RapidAPI, Apify, Zapier, and n8n in `packages/marketplace`; and introduced an initial MCP package in `packages/mcp` that exposes Wisprs media workflows as agent tools on top of the stabilized `/api/v1` API. Updated the starter OpenAPI spec in public/api/openapi.yaml to match the expanded contract.
- Founder-initiated user messaging + Sage outreach drafts + churn-risk auto-draft (2026-06-17). A way to proactively reach a user (not just reply to inbound). Four entry points open a MessageUserDialog (message-user-dialog.tsx): the support sidebar "New message" + inbox "New" button, the transcription detail page ("Message user about this", contextual to a specific job), and the user detail page. Compose UI at `/admin/support/compose` + routes (`/api/admin/support/compose`, `/api/admin/users` lookup); Sage drafts the outreach copy (support/outreach.ts, agent.ts, store.ts). Churn-risk auto-draft: when a transcription fails for a churn-risk user (no successful transcription yet, or paid/trial), the bridge poller enqueues a new `SUPPORT_OUTREACH_DRAFT` job (support-outreach-draft-job.ts) that has Sage pre-draft a founder outreach message onto `transcription.metadata.outreachDraft` — ready for one-click send (e.g. the real Pedro #130 failure). New outreach event type wired to Slack + in-app notifications (events/types.ts, slack.ts, in-app-map.ts). Includes a hard-navigation fix on the messaging entry points. No new migration.
- Agent capability layer — per-agent callable tools, bidirectional + hierarchical (2026-06-17). The 28 CMO agents were loosely coupled: the only lever was the coarse `runAgentNow` (fire the whole agent). Now each agent exposes capabilities — specific read/command functions — invoked through one central invoker by the CMO (Moxxie), the app/operator, or (rails-ready) another agent. Strict-tree hierarchy (policy.ts `canInvoke`): reads open to all; commands follow the tree — CMO→any non-advisory agent, parent→its own sub-agents only, no peer→peer, no sub→up, no self; refusals recorded as `denied`, never silent. Risk-tiered governance: reads instant; low-risk commands auto+logged; high-risk (e.g. `cold-email.runPipeline` which sends mail) queued as a `proposed` invocation for founder approval (operator-initiated runs immediately). Every call audited to new `cmo.agent_invocations` (migration 0041). Capabilities are declared in registry.ts as thin wrappers over existing service fns (add one = one entry); v1 ships free-tools (status/getJob/reapStuck), directory (status/runNow), content-orchestrator (pipelineStatus/quality/runNow), seo-auditor (organic/runNow), cold-email (readiness + gated runPipeline). Surfaces: Moxxie chat tools `discoverAgentCapabilities` + `invokeAgentCapability` (capability-tools.ts); app `POST /api/admin/cmo/agents/[id]/invoke` + a Capabilities panel (run + approve high-risk) on `/admin/cmo/agents`; high-risk approval via `POST /api/admin/cmo/invocations/[id]`. Inventory + model in project_docs/cmo/AGENT_INVENTORY.md + AGENT_DISPATCH_MATRIX.md.
Fixed
- Meeting capture extension hardening — tab-only fallback, tighter history payloads, and no false stalled finalization (2026-06-18). Tightened the Phase 1 Chrome extension around the actual failure modes we saw in local testing. The microphone permission flow now marks `micJustGranted` so the popup can return users to a primed ready state after the dedicated permission page succeeds (permission.js, popup UI). The extension history feed is now intentionally capped to the latest 5 transcripts and the "Open in Wisprs" CTA resolves through the dashboard transcription history sign-in redirect instead of a generic app entry point (recent-transcriptions route, popup history CTA). On the server side, `deriveMeetingSessionStatus` no longer degrades `finalizing` or terminal meeting-session states into `stalled` just because the heartbeat ages out; only genuinely stallable states are eligible, with regression coverage added in `meeting-capture.test.ts` and extension-header/auth helper coverage in `meeting-capture-api.test.ts`. Meeting-capture docs and the architecture plan were updated to reflect the tab-audio fallback and the now-shipped stale-session watchdog behavior.
- Prod outage (~2h) + new health-monitor service — nginx couldn't rebind :443 after a cert renewal (2026-06-17). A routine `certbot.timer` renewal restarted nginx at 04:16 UTC; Tailscale Serve was holding :443 (enabled sometime after Jun 11), so nginx failed `bind() to 0.0.0.0:443 (Address already in use)` and stayed down — taking all ~10 sites on the box with it for ~2h. It was invisible because the app itself stayed healthy on `localhost:2222` and nothing watched the public edge. Fix: `tailscale serve reset` (freed 443) + `systemctl restart nginx`. To prevent recurrence + close the monitoring gap, added a config-driven health-monitor service (devops/monitor/run-monitors.sh + monitors.json, systemd timer every 60s) that hits the public `https://wisprs.co/api/health` (catches nginx/TLS/app down — the exact miss), plus `bridge.healthy`, all queues `ready`, and `systemd is-active` for nginx/app/stt-bridge — alerting to the existing `SLACK_WEBHOOK_URL` on state change only (down + recovery, with a 30-min re-alert while down). Add a monitor by appending one entry to `monitors.json`. Runs on-box (catches service-level failures incl. a healthy-app-but-dead-edge); pair with an off-box uptime check for whole-box death.
- Ask Moxxie / Ask Sage — AI bubbles now hug their content instead of stretching the column (2026-06-16). A regression from the full-page canvas change: the assistant message wrapper was `flex-1`, so even a one-line reply filled nearly the whole width — no left/right visual separation from the user bubble. Now the assistant wrapper is capped (`min-w-0 max-w-[80%]`) and the bubble is `w-fit max-w-full` (width: fit-content), so short replies stay small and left-aligned, long ones wrap at the cap, and wide tables still scroll inside the bubble — matching how the user bubble already hugs its content (cmo-chat-panel.tsx, assistant-panel.tsx). Gated/diff cards still take the full capped width.
- Ask Sage — per-message timestamps under each bubble (2026-06-16). Sage's chat bubbles had no timestamps (its `fmtThreadTime` was only for the history list), unlike Ask Moxxie where every bubble is stamped. Added the same treatment (assistant-panel.tsx): a clock stamp below each user + assistant bubble — persisted turns use the stored `ts` (carried through on rehydrate via `metadata.ts`), live turns get a stable client stamp on first render. Brings Sage in line with Moxxie.
- Ask Moxxie / Ask Sage — full-page chat canvas + bubble layout that doesn't clip wide content (2026-06-16). On the dedicated chat pages the canvas was capped narrow (`max-w-4xl` / `max-w-5xl`), so a wide markdown table in a reply overflowed its bubble, got clipped, and shoved the right-aligned user bubble off-screen (the founder couldn't see their own message). Fixed across both panels (cmo-chat-panel.tsx, assistant-panel.tsx): the panel now fills the page width (`w-full`, full-height calc), while each message row is centered to a readable `max-w-4xl` column (so the user-right / agent-left chat flow is preserved instead of stretching edge-to-edge), and assistant/user bubbles get `overflow-x-auto` + `min-w-0` so a wide table/code block scrolls inside its own bubble (with a slim scrollbar) rather than breaking the layout. The composer + slash menu are centered to the same column. Embedded cockpit panel is unaffected.
- Moxxie chat — context-overflow "network error" on long threads + no retry (2026-06-16). After ~10 turns the Ask-Moxxie chat died with a bare red "network error" and no way to recover. Root cause (confirmed from prod `llm_calls` + `cmo.chat_messages`): not a timeout (nginx allows 300s, `maxDuration` is a no-op on self-hosted) — it was context-window overflow. Every turn re-sent the full system prompt (~50K tokens) + the entire growing history + every prior turn's fat tool results (each `getEngineState`/`searchKnowledge`/`readFile` payload is multi-KB), compounding across the 8-step agentic loop — one turn already hit 106K input tokens, and the next turn tipped over the model limit, so the stream threw before `onFinish` (no `llm_calls` row, no persisted reply) and `useChat` showed the generic error. Three fixes: (1) chat-agentic.ts now bounds model input — the UI keeps the full thread, but the model gets the last 80 messages with prior tool-call/result/reasoning parts stripped to text (only the current turn's tools run fresh), so ~100 conversational messages cost little and a 50K tool dump is never re-shipped; (2) a `streamText` `onError` logs the real reason (greppable `cmo-chat` warning) and the route's `toUIMessageStreamResponse({ onError })` maps it to a readable message (context-overflow gets its own hint) instead of "network error"; (3) the chat panel (cmo-chat-panel.tsx) renders a Retry button (`useChat` `regenerate()`) on error so a broken turn re-runs in one click. The user message was already persisted up-front, so nothing's lost.
- Ask Sage history rail — brought in line with the inbox design system (2026-06-16). The new chat-history sidebar looked flat next to the polished support-inbox rail. Restyled to match exactly: the same brand-gradient (violet→pink→amber) divider + collapse chip, a `w-9` vertical strip with a rotated label when collapsed, a proper header (icon chip + count badge + a bordered "New" pill), and rounded-2xl thread cards with the violet→pink active gradient + hover ring + per-thread icon (assistant-panel.tsx).
- Gated-action approval cards — disable + show status while the verdict is applied (2026-06-16). Clicking Approve on a Moxxie/Sage proposal (especially a `proposeDocEdit`, which commits to GitHub and takes a few seconds) left the buttons live with no feedback — it looked like nothing happened, and a double-click could fire the action twice. Both cards now disable Approve/Reject the instant one is clicked and show an in-flight status (spinner + a contextual verb — _Committing… / Sending… / Applying… / Rejecting…_) until the execute-tool result lands, backed by a ref guard against double-submits (cmo-chat-panel.tsx, assistant-panel.tsx).
- Auth-session reconcile worker failed to register on prod (2026-06-16). The daily reconcile cron logged `Queue auth-session-reconcile not found` at boot — pg-boss creates queues from the `createAllQueues` config list, not lazily on `work()`/`schedule()`, and the queue was missing from that list. Added the config entry (mirrors `orphan-file-reaper`) so the cron actually schedules (queue/index.ts). The webhook `session.removed`/`session.revoked` close-handling was unaffected; only the backlog sweep was paused until redeploy.
- Admin activity — "sessions per user" was a cookie artifact, not engagement; + auth-session reconcile (2026-06-16). The `/admin/users/activity` card "Avg sessions / active user (7d)" read ~1 for everyone and looked broken. Root cause: it divided _new sign-ins in 7d_ (numerator, from `auth_sessions`, which only gets a row on a fresh Clerk `session.created`) by _distinct active users in 7d_ (denominator, from `user_activity_daily`) — two different populations, since a returning user on a persistent cookie creates no new session row. Replaced it with "Avg active days / user (7d)" — real stickiness from `user_activity_daily` (queries.ts) — relabeled "Logins today → Sign-ins today", and reframed the sessions tab + copy as an auth/device audit, not engagement (activity/page.tsx, [id]/page.tsx). Also fixed ghost "Open" sessions: the Clerk webhook now closes a row on `session.removed`/`session.revoked` too (not just `.ended`) (webhooks/clerk/route.ts), and a new daily reconcile job (auth-session-reconcile-job.ts → reconcile-sessions.ts) closes stale open rows via the Clerk API + a `AUTH_SESSION_MAX_AGE_DAYS` fallback (backfill CLI: scripts/activity/reconcile-auth-sessions.ts). Dashboard guidance for the founder in devops/CLERK_SESSIONS.md. No migration — reuses the existing `endedAt` column.
- Support ticket insights — stop re-running the AI brief when reopening a replied/closed ticket (2026-06-16). The right-rail AI insights are cached on the conversation keyed by message count (insights/route.ts), but a founder reply appended a message without re-caching — so the next open found `cached_count ≠ actual_count` and regenerated the whole brief (one was a 125-second kimi call). Two fixes: a closed ticket now always serves its cached brief (frozen — never regenerates), and a founder reply carries the cache's freshness marker forward to the new message count ([id]/route.ts) so reopening doesn't trigger a needless regen (a genuine new _user_ message still re-warms via the existing pre-cache path). Also fixed the `support-assistant` ledger rows logging with a null `ref_id`.
- CMO cold-email warmup ground truth — engine-state was misreporting the ramp (2026-06-15). `coldEmailReadiness` (Moxxie's `getEngineState` source) said warmup "ramps daily caps over ~4 weeks" with no full-caps date — wrong, and the literal reason the agent kept quoting "~4 weeks" / docs guessing "~10 days". The real ramp (cold-email/warmup.ts) is 3 weeks — wk1 50% / wk2 75% / wk3 90% / 100% from week 4 (day 21), counted per-mailbox from its own `warmup_started_at`. Fixed the note + now surface `rampPctByWeek`, `fullCapsAtDay: 21`, `currentWeekPct`, `steadyDailyCapTotal`, and per-mailbox `fullCapsDate` (metrics.ts) — the ramp schedule lives in source the agents can't read, so it's exposed through the tool. (Live prod: Google `trywisprs.com` cap 20 → full Jun 25; M365 `getwisprs.com` cap 5 → full Jun 28.) This is the data source the agents will ground their doc-date corrections on post-deploy.
- Sage degeneration — repetitive walls + hallucinated sends (2026-06-15). The Sage chat model (`kimi-k2.6:cloud`) was degenerating into repetitive run-on filler ("Sent — user emailed on WIS-0001." ×N, "Standing by.", "Ready when you are.") and even falsely claiming sends ("Sent — user emailed on WIS-0002" while that ticket was still pending approval). Root cause: a degenerate post-action ack landed in history, then `messages.slice(-20)` fed it back so later turns mimicked the pattern — a self-reinforcing loop. (Verified the actions themselves were correct: exactly one email per real approval.) Two fixes: (1) removed the post-verdict LLM round-trip in `assistant-panel.tsx` — the resolved proposal card already renders the deterministic confirmation ("✓ Reply sent on WIS-0001 — the user has been emailed."), so that IS the acknowledgment (no model turn → no new degenerate text polluting history); (2) added a `maxOutputTokens: 800` backstop on the assistant route so no single generation can run away into a wall (a draft + reasoning fits in ~400). Prompt-level "keep terse" couldn't reliably stop the repetition; these are deterministic bounds.
- "Try again" in the error screen now actually recovers (2026-06-15). The global error boundary (src/app/error.tsx) called only `reset()`, which re-renders the errored boundary against the same cached (failed) server render — so after a transient network/DB blip "Try again" looked like it did nothing. It now `router.refresh()`-es to refetch the server-component data, then `reset()`s, wrapped in a transition (button shows "Retrying…" + disables while in flight). Standard Next.js app-router error-recovery pattern.
Added
- `deploy-quick.sh` — one-command fast deploy for small app changes (2026-06-16). A 9–25 min VPS build for a few-line UI change was the norm. The local-build fast path already existed (`DEPLOY_LOCAL_BUILD` builds `.next` on the dev machine + rsyncs it) but was a long command with footguns. scripts/deploy-quick.sh packages it: runs pre-flight, pulls prod `NEXT_PUBLIC_*` into a temp `.env.production.local`, sets the SSL/pool/inline-key flags, cold-builds by default (CSS-safe; `QUICK_WARM=1` for logic-only incremental), rsyncs, and cleans up — still routed through `deploy-wisprs.sh` so it keeps the git-sync guard, build lock, migrations, and restart. Use it for UI/route/lib changes with no migration; use the full server build for schema changes. Documented in the wisprs-deploy skill.
- Deploy git-sync guard + ambient local autosync — never ship stale / drop agent commits (2026-06-16). The deploy rsyncs the local working tree to prod; it does NOT pull from GitHub. So agent doc-edits (Moxxie/Sage `proposeDocEdit` commits land straight on `origin/main` via the Contents API) and any other remote work only reach prod through the local checkout — a deploy from a behind-local would ship stale files and silently drop those commits (prod has no `.git`, so local is the only relay). Two fixes: (1) deploy-wisprs.sh now `git fetch`es and, before touching prod, fast-forwards local to `origin/<branch>` when it cleanly can — aborting with guidance on divergence or a blocked ff (override `DEPLOY_SKIP_GIT_SYNC=1` for emergencies); so every deploy provably carries the latest remote work. (2) a safe, fast-forward-only git-autosync.sh (never rebases/force-updates; `--ff-only` refuses + logs if it would clobber a locally-modified file) wired to a `launchd` agent (`co.wisprs.gitautosync`, every 15 min) keeps the local checkout current with Moxxie's commits without manual `git pull`. The launchd plist is machine-local (not committed).
- Ask Sage — persisted chat threads + retractable history sidebar (2026-06-16). The Ask Sage assistant (assistant-panel.tsx) was ephemeral — every reload started blank, no way to revisit a past conversation. Ported the proven Moxxie threading pattern into the support module: two new tables `support.assistant_conversations` + `support.assistant_messages` (support/db/schema.ts, migration 0040), a best-effort store (assistant-store.ts), and thread list/create/history routes (conversations/route.ts + `[id]`). The stream route now accepts a `conversationId`, persists both sides of each turn (text + tool names + full AI-SDK telemetry), auto-titles from the first message, and stamps the `llm_calls` ledger with the conversation id. The panel gained a retractable right-side "Chat history" rail (New chat, click-to-reopen rehydrates the thread, collapse state persisted to localStorage) mirroring the inbox/Moxxie layout. Backend verified end-to-end (create → turn → rehydrate → ledger) against a local dev server. No new deps.
- CMO brief is now an actionable task board — comment, check off, dispatch, auto-verify (2026-06-16). Moxxie's daily brief was static: priorities + "Directives to the team" lived in `cmo.briefs.body` jsonb with no ids/status/comments, overwritten every regenerate, so the founder couldn't action them and Moxxie never knew what got done. Now every priority + directive materializes into a stateful task (new `cmo.brief_tasks` + `cmo.task_comments`, migration 0039) — a rolling backlog: a stable `taskKey` (`source:track-or-agent:slug(title)`, task-key.ts) dedupes a recurring recommendation onto the SAME task across days, finished items drop off and stop being re-raised. Inline on the brief (+ a dedicated board at `/admin/cmo/tasks`) each item gets a status toggle, a comment thread, and—reversibly, no approval gate—auto-verify: marking a task done re-reads the metric it was meant to move (verify.ts maps track→`getLeadingIndicators`/`channelReadiness`: directory live count↑, free-tool failures↓, organic clicks↑, content published↑, support open↓, lifecycle sends↑) versus a baseline captured when it opened, writes a templated verdict (_"Directory live count 12 → 15 (+3) — confirmed."_), and flips to `verified` on confirm (a non-confirming verdict never un-does the founder's "done"). A daily sweep re-verifies before each new brief. One-click dispatch fires the task's sub-agent now via the existing `runAgentNow` rails (agent-dispatch.ts), logs an `agent_decisions` row, links the resulting `agent_run` back to the task, and folds the run summary into the verdict — for the 12 pg-boss + 4 skill executable agents; the 9 advisory ones stay the founder's (an Agent Dispatch Matrix on the board + doc makes the whole team's wiring legible). Task state feeds back into the next brief's gather context so recommendations self-update, and Moxxie can run the board in chat via a new `getBriefTasks` read tool + gated `setTaskStatus`/`verifyBriefTask`/`dispatchTask` write tools.
- Free-tool downloaders show _why_ a source is over the limit + its metadata (2026-06-15). When a YouTube/TikTok download (youtube-mp4/-mp3, tiktok-mp4/-mp3) tripped the free cap, the user saw a vague "This source exceeds the free limit (200 MB / 60 min) or is a livestream" and nothing else — because the download handed yt-dlp `--match-filter`/`--max-filesize` and let it reject pre-download with an opaque "does not pass filter", so we never captured _which_ limit or the source's numbers (a confirmed-failed job had `provider_metadata: null`). Now the providers probe-then-decide: a shared `probeMediaMeta` reads the source's title/duration/filesize/livestream from the `-J` probe the providers already ran for the title, `evaluateFreeToolCap` picks the cap in our own code (priority livestream → duration → filesize) and on a miss throws a `FreeToolCapError` carrying the metadata. The worker persists it to `free_tool_jobs.provider_metadata` (`markToolJobFailed` gained an optional metadata arg), the job-status API exposes it as `cap: { title, durationSeconds, filesizeBytes, isLive }` (route), and the YouTube + TikTok tool clients render a specific message ("This source is 84 min — over the free 60-min limit", "This is a livestream — not supported") plus a compact metadata block (title · Duration · Size · Livestream badge). The existing `--match-filter`/`--max-filesize` + post-download `assertOutputWithinCap` stay as belt-and-suspenders for adaptive streams the probe can't size (YouTube often omits a total); a probe failure (anti-bot) degrades gracefully to the capped download + generic message. No migration, no new deps.
- Agents can edit docs — gated, human-in-the-loop (2026-06-15). Both Moxxie and Sage gained a shared `proposeDocEdit` tool (src/lib/docs/doc-edit.ts) so they can _fix_ docs, not just describe fixes (prompted by Moxxie spotting stale warmup dates it couldn't touch). It's a find-and-replace — the agent supplies `{ filePath, oldText, newText, reason }` (never re-emits a whole file, dodging the repetition-degeneration), so the diff is intrinsic to the input. Like the agents' other gated actions it has no `execute` → it halts as a proposal; the founder reviews a red/green diff in the chat (doc-edit-diff.tsx in both the Moxxie + Sage approval cards) and only on Approve does the existing execute-tool route apply it and commit to `main` via the GitHub Contents API (github-writer.ts) — prod has no `.git`, so a commit (not an FS write) is the durable path; it lands on prod at the next deploy. Guardrails: path confined to `content/`,`project_docs/`,`docs/` + `.md`/`.mdx` only (reuses `safeResolve` — no source/env/config), `oldText` must match exactly once (else fails closed, no write), every verdict audited (`agent_decisions`/`support_assistant_action`), and the doc-edit verdict is terminal (no LLM ack round-trip). Needs `GITHUB_TOKEN` (or `GH_TOKEN`) on the server.
- CMO retrieval-eval — weekly cron + shared lib (2026-06-15). So the Learning tab's Retrieval panel self-refreshes instead of needing a manual script run, the eval compute+persist is extracted into a shared lib (`src/lib/cmo/eval/retrieval-eval.ts`, `runRetrievalEval()`) used by both the CLI and a new weekly pg-boss job (`cmo-eval-job.ts`, `CMO_EVAL` queue, `CMO_EVAL_CRON` default Mon 07:15 UTC — after the daily reindex). The job scores the `golden-queries` + `golden-tactics` sets → a `cmo.eval_runs` row each, so recall@10/nDCG/MRR trend automatically. No DB wipe on deploy, so a recorded score persists; the cron just keeps it fresh. Engine-gated; missing golden files are skipped, never fatal.
- CMO "Learning" telemetry tab (2026-06-15). A new read-only Learning tab on `/admin/cmo/telemetry` (Overview · Learning · Calls · Decisions) makes Moxxie's learning side a first-class telemetry surface, not just LLM-ops. Three areas: Index health (knowledge_chunks total + by source, distinct sources, lessons/patterns counts, last-ingest, crawled-docs awaiting ingest — from existing `knowledgeStats`/`getKnowledgeFiles`/`listLessons`), Retrieval quality (recall@10 / nDCG@10 / MRR vs SLO targets + Δ vs the previous run + a recent-runs list), Lessons learnt (most-corroborated lessons with confidence + corroboration, plus a "Manage in Brain →" deep-link — no duplicate editor), and a What changed trail (recent prompt-section edits + every brief regeneration). Retrieval eval scores were previously stdout-only; new `cmo.eval_runs` table (migration `0037`) + `scripts/cmo/eval-retrieval.ts` now persists one row per run (`--no-persist` to skip), read via `getLearningTelemetry()`. The Retrieval panel shows an empty-state until the eval job first runs against the target DB. Read-only; composes existing readers (ports/adapters boundary preserved).
- Sage support agent — agentic ticket handling with human-in-the-loop approval (2026-06-15). Sage could only read; now she can DO everything the support GUI human can, via gated tools, so the founder runs support conversationally. Read tools (autonomous) gained `getConversationThread` — read one ticket's full meta + message history (so a draft is grounded in what the user actually said). Action tools (`assistant-write-tools.ts`, no `execute` → they halt as approval cards): `sendSupportReply` (emails the user as the founder), `setTicketStatus`, `updateTicketFields` (subject/type/priority/assignee/internal note), `setConversationAiMode`. Severity rule per the founder: reads run freely; every send/update requires explicit approval — calling an action surfaces an Approve/Reject card (send-a-reply flagged red as it emails the user), and only on approve does `executeSupportWriteTool()` run the exact same store mutations + events the GUI fires (`appendMessage` + `support_reply_sent` email, `updateTicketMeta`, `support_ticket_resolved`, `setConversationAiMode`). Reuses the proven CMO pattern end-to-end: gated tools in the streaming route, a verdict execute-tool route, and the panel's `useChat` `addToolResult` + `sendAutomaticallyWhen` auto-continue so Sage acknowledges after approval (`assistant-panel.tsx`). No new backend CRUD — it wraps what already exists.
Fixed
- Chat composers auto-grow instead of scrolling (2026-06-15). Every message input had a fixed `rows` height, so a two-line reply already showed an inner scrollbar (visible in the support inbox composer). New shared `ChatTextarea` — a drop-in `<textarea>` that grows line-by-line up to the existing `max-h-*` CSS cap (read live, so each surface keeps its own cap) and only then shows a scrollbar; it also re-fits on programmatic seeds (AI-draft prefill, suggestion clicks, reset-after-send). Applied across all chat inputs: support inbox reply (`support-thread.tsx`), customer widget (`support-widget.tsx`), customer ticket reply (`ticket-thread-client.tsx`), Ask Sage (`assistant-panel.tsx`), and Ask Moxxie GUI + terminal (`cmo-chat-panel.tsx`).
- CMO telemetry tabs + brief controls polish (2026-06-15). Reordered the AI Telemetry tabs so Overview leads (and is the default) with Decisions last (cmo-telemetry-client.tsx) — Decisions is empty until Moxxie proposes a gated action in chat, so it shouldn't be the landing tab. Brought the brief-header Today stepper + Regenerate button into the same violet→pink brand-gradient family as the Ask Moxxie pill (they were flat glass) (brief-rewind.tsx, brief-regenerate-button.tsx).
- Soft-delete for user content — closes a free-tier usage-cap evasion + audit gap (2026-06-15). Deleting a transcription was a hard `db.delete`, and the free daily-minutes cap is computed by `SUM(transcriptions.duration)` (usage.ts → gate.ts) — so a free user could delete a completed job to wipe its minutes and re-upload past the 30-min/day limit (observed in the wild). It also corrupted lifetime analytics and left no audit trail or explanation for the ID gaps. Fix: user-initiated deletes now set a `deleted_at` timestamp instead of removing the row (migration `0036`, nullable + indexed on `transcriptions`, `folders`, `tts_synthesizations`, `crawl_jobs`, `crawled_documents`, `shared_transcripts`). The surviving row keeps counting toward usage caps and analytics; user-facing reads hide it via a new `notDeleted()` helper (dashboard list/stats, single-transcript page, exports, stream/translate, library, search, folders + media, dedup-reuse, share resolution, TTS history), while billing/quota/analytics/admin queries deliberately keep counting it (this is what closes the hole — no query change needed there). `deleteTranscription`, `deleteFolder` (now un-files its media explicitly, since the `SET NULL` cascade no longer fires), and `deleteTTS` all soft-delete + are idempotent. Storage is still reclaimed on delete (the file is genuinely freed; only the minutes-rate cap must resist deletion). New `transcription_deleted` audit event (events/types.ts) — admin Slack signal, no user email. Admin Transcriptions page (admin/transcriptions/page.tsx) gains a Deleted stat card + filter and a deleted badge alongside the preserved original status. Rollback-path deletes (failed-row cleanup) stay hard. Soft-deleted rows are kept indefinitely. _(Note: already-hard-deleted #120/#121 are unrecoverable; this prevents recurrence.)_
- Engine ticker header — balanced status cluster (2026-06-15). The right-side header on the Engine ticker card (`moxxie-ticker-card.tsx`) had a bare uppercase LIVE label crammed against three loose icon+count pairs of mixed weight. Resolved into two cohesive, evenly-weighted pills — a self-contained LIVE/idle status badge (emerald ring when live) and a single segmented counts pill (runs · LLM calls · decisions) with thin dividers and consistent padding — so the cluster reads as deliberate chrome and balances visually against the title.
- CMO brief header — premium polish (2026-06-15). Dropped the raw model tag (`KIMI-K2.6:CLOUD`) from the brief header — it's still stored on the brief row + in the timestamp tooltip, just not shown in the chrome (`cmo-brief-card.tsx`). The header controls now match the app's brand aesthetic: Ask Moxxie is a violet→pink gradient pill carrying Moxxie's own avatar (not a generic chat glyph), and the Today rewind stepper + Regenerate button share a violet-tinted glass treatment with `#8B5CF6` hover states (`brief-rewind.tsx`, `brief-regenerate-button.tsx`).
- Alerts pointed at `http://127.0.0.1:2222` instead of `https://wisprs.co` (2026-06-15). Slack alerts (transcription-failed, orphan, support, etc.) and transactional emails were linking to the app's internal bind address behind nginx. Root cause: server-side notification code read `process.env.NEXT_PUBLIC_APP_URL` directly, and Next.js inlines `NEXT_PUBLIC_*` at build time even in server code — so a build that ran with a bad/local value baked `http://127.0.0.1:2222` permanently into the bundle, and the `?? 'https://wisprs.co'` fallback never fired (the baked value isn't null). Prod's runtime `.env.production` was already correct (`https://wisprs.co`) — only the baked bundle was wrong. New runtime-resolved, loopback-rejecting `serverPublicOrigin()` (prefer runtime `APP_ORIGIN` → else `NEXT_PUBLIC_APP_URL` unless loopback → else `https://wisprs.co`) now backs the Slack (`providers/slack.ts`) + email (`providers/resend.ts`) link builders and share-URL fallback (`generate-token.ts`); even a mis-baked bundle emits public links, and `APP_ORIGIN` is a runtime override knob immune to build-time inlining.
- STT bridge false `bridge_down` alerts while it was up and transcribing (2026-06-15). The health monitor (`/v1/health` probe, 10s timeout, 3-fail debounce) fired `bridge_down` during long jobs even though the bridge was alive and working. Root cause: on the 12-core VPS the bridge ran with `BRIDGE_STT_WHISPER_CPU_THREADS=0`, which ctranslate2 reads as "use ALL cores" — transcription pinned every CPU at 100%, CPU-starving the FastAPI `/v1/health` threadpool thread so the probe timed out for the whole job (a multi-minute CPU-bound transcription easily exceeds the ~6-min debounce). Fixed in the bridge (`stt-bridge/main.py`): when `BRIDGE_STT_WHISPER_CPU_THREADS` is unset/0 it now defaults to leave ~2 cores free (`cpu_count − 2` on boxes with >3 cores) so liveness probes are always scheduled during transcription; an explicit env value still overrides. Negligible transcription-speed impact (10 vs 12 cores). Metrics now report the effective thread count, not the raw `0`. Takes effect on bridge restart (next deploy restarts `wisprs-stt-bridge`).
- CMO — full stack/vendor awareness + chat-history UX (2026-06-15). Asked _"is the Resend drip working?"_, Moxxie misread Resend (our email vendor running the lifecycle drip) as "re-send to non-openers" — she had no map of the real infrastructure behind each engine. Two fixes: (1) Stack map. A new always-on, GUI-editable prompt section `stack` (`adapters/config.ts` — added to `PromptSection`/`PROMPT_SECTIONS`/`PROMPT_LABELS`/`PROMPT_DEFAULTS`, so it auto-renders + versions/reverts in the Brain → Prompt tab) names the real vendor/library behind every channel — Resend (lifecycle/drip), Mautic + Reacher + SMTP rotator (cold email), ElevenLabs/OpenAI (TTS), self-hosted Whisper bridge + ElevenLabs Scribe (STT), crawl4ai (crawler), Ollama Cloud kimi-k2.6 + OpenAI embeddings (LLM/RAG), Polar/Clerk/pg-boss/GA4·GSC·Ahrefs·Mixpanel/IndexNow. Injected into both prompt builders (`brain.ts` brief + `chat-agentic.ts` chat) so she binds vendor names correctly and never re-reads "Resend" as a verb. (2) Data-layer reinforcement. `channelReadiness()` now tags each engine block with a `vendor` string (lifecycle→Resend, coldEmail→Mautic+Reacher+rotator, support→in-app+Resend, content→pipeline+crawl4ai, freeTools→Whisper bridge, directories→manual+Ahrefs) so even from live state the vendor is explicit. No migration. Ask-Moxxie chat history UX (`cmo-chat-panel.tsx`): the recents list now stamps "Tue 12:14" (weekday + 24h; older-than-7d prefixes the date) instead of bare time, and opens as an in-flow right rail beside the thread (`flex` row: chat column `flex-1` + `w-72 border-l` rail) instead of an absolute popover overlaying the canvas — works in both standalone and embedded (Command Center) modes, preserving gui/tui theming.
- CMO brief — always-on force-regenerate + append-only version trail (2026-06-15). The Regenerate button was a no-op once today's brief existed (single-flight). Now `generateCmoBrief({ force })` bypasses single-flight for explicit regenerates (the Command Center button → `/api/admin/cmo/brief/refresh`, and the chat `regenerateBrief` write-tool), while the cron + boot self-heal stay single-flight (never double-spend). To make force-regen non-destructive, every generation (incl. same-day re-runs) is appended to a new `cmo.brief_versions` log (migration `0035`) — `cmo.briefs` keeps the canonical current-per-day row, this keeps the full trail. New `listBriefVersions()` + a `getBriefVersions` read-tool let Moxxie see how a day's read evolved (not just the final overwrite), feeding the learning loop.
- CMO brief — context-aware diagnosis from real-time engine state (2026-06-15). The daily brief reasoned from static metric snapshots and opened with _"Emergency cold-email forensics — 9,657 leads, 0 signups is an infrastructure bug"_ while cold-email was intentionally mid-warmup (a dedicated agent ramping it; `cold-email` is `phase:'warming'`). Root causes + fixes: (1) the brief's gather was blind to engine state — added `getEngineState()` (per-agent phase/health/overdue from `getRoster`, recent agent runs + outcomes from `agentRunStore.feed`, and the explicit cold-email warmup truth — `isWarming`, warmup day/week, effective caps, sends/replies — via new `coldEmailReadiness()`), gathered into `brain.ts` and exposed as a `getEngineState` read-tool for Ask-Moxxie. (2) `OPERATING_PROCEDURE` (persona.ts) now mandates checking engine state in GATHER and adds a diagnose-with-system-context rule: a metric that's low because its channel is intentionally warming/paused/just-changed is expected, not a bug — flag a break only when the agent's own runs show errors. (3) `recentlyShipped()` ran `git log` against prod's `/opt/apps/wisprs` (no `.git` → always `[]`); now the deploy writes a `.deploy-manifest.txt` (last-48h commit subjects) that the running build reads, restoring git/"what shipped" awareness on prod.
- CMO brief — full PLG system visibility, every engine (2026-06-15). Extended `getEngineState` from cold-email-only depth to a per-engine `channels` pulse so Moxxie reasons across the whole system. New `getSupportSystemPulse()` (a single `COUNT(*) FILTER` aggregate over `support_conversations`) closes the one true blind spot — support was a separate module with only per-user queries, so the CMO had zero system-wide ticket visibility. `channelReadiness()` now composes a compact, fail-soft snapshot per engine — coldEmail (warmup), support (open/awaiting-founder, paid-tier friction, 7d volume), lifecycle/drip (`getSequenceStats`/`getSendVolumes` — flags a silent drip), content (`getContentPipelineStats`), freeTools (`getFreeToolStatusCounts` + funnel), directories (`getDirectorySubmissionStats`) — each with a one-line judgment note. `engineState.coldEmail` → `engineState.channels.*`; `OPERATING_PROCEDURE` (persona.ts) generalizes the diagnose-with-context rule to every channel and makes support a first-class growth signal (paid-tier tickets awaiting reply = churn risk that can outrank a vanity metric). Read-only, no migration.
- Sage support chat — reuse the Moxxie markdown + streaming flow (2026-06-15). The Ask Sage panel rendered raw text in a `whitespace-pre-wrap` div (bold/lists/headings showed as literal `**`/`-`) and was non-streaming (POST → full JSON), unlike the Moxxie CMO chat. Extracted the shared chat renderers — `Prose` (Streamdown markdown), `TuiProse`, `ThinkingDots` — into `src/components/admin/chat/markdown.tsx`; both the CMO panel and the Sage panel now import them (no duplicated prose styling). Sage's panel was rewritten onto `useChat` + `DefaultChatTransport` (live token streaming, tool chips, stop button, thinking states), and its API route converted from `generateText` → `streamText().toUIMessageStreamResponse()` (same tools + `llm_calls` telemetry). Sage keeps its own identity (Sage avatar, founder avatar, suggestion chips); only the data/persona differ from Moxxie — formatting is now uniform across both admin AI chats.
- CMO daily brief — fires at 8am EAT + self-heals + point-in-time rewind (2026-06-15). The brief cron was `0 9 * * *` UTC (= noon EAT), so the founder never had it by their 8am. Re-timed to 08:00 `Africa/Nairobi` (new `CMO_BRIEF_REFRESH_TZ`, passed to pg-boss `schedule(..., { tz })` in `cmo-brief-refresh-job.ts`) so "8am" means 8am for the founder. Added a boot-time self-heal — registration enqueues one run, and `generateCmoBrief()` is single-flight (skips if today's row exists), so a deploy or a missed tick backfills today's brief instead of leaving the curated fallback. Clarified the "lost after deploy" worry: briefs were never a volatile cache — each day is a durable `cmo.briefs` row — so added a rewind to the Command Center brief card (`brief-rewind.tsx` + `listBriefArchive()`): a `‹ Today ›` date stepper to page back through past days exactly as Moxxie wrote them (Regenerate hidden on historical days), mirroring the North Star calendar rewind.
- Subscription stats — exclude the founder/admin comp sub (2026-06-14). MRR already filtered `role <> 'admin'`, but the subscription counts in `getSubscriptionMetrics()` (active / by-plan / trialing-by-plan / new-this-month / churned / orphans) did not — so a founder subscription added straight to the DB (never charged through Polar) was inflating the active/plan counts and showing up as a false "orphan" (no `polar_customer_id`). All six aggregates now join `users` and exclude `role = 'admin'`, reconciling the Subscriptions module with MRR and the North Star (which already excluded admins). Row listings are untouched, so the sub stays visible/manageable.
- User counts — one source of truth across admin pages (2026-06-14). The portal home read "Users 44" while `/admin/users` showed "Total users 45 / Admins 1" — not staleness but a definitional split (44 non-admin rows vs 45 all rows = 44 + 1 comp admin). Unified every headline on the canonical `getUserCounts()`, extended to return `admins` + `totalAll` (= total + admins) alongside `total` (lifetime non-admin users — the business "Users" count). `/admin/users` dropped its three inline `COUNT(*)` queries for the helper (cards now read Users / Admins / Signups-7d; the all-rows count survives only in the table's "of N" pagination, where it sits next to Admins=1 and reads unambiguously). `/admin/email` overview relabeled "Total users" → "Users" and `getOverviewSummary()` now excludes admins so the drip-funnel denominator matches. Same word → same number everywhere; per the metrics rule, different metrics keep different labels.
Added
- CMO "Moxxie Brain" console — see / edit / train / steer the AI CMO's brain (2026-06-14). A single admin console at `/admin/cmo/brain` (new "Brain" nav item) to make Moxxie's previously code-only brain fully GUI-managed, in four tabs:
- Graph — an interactive 2D force-directed knowledge graph of the whole corpus (`brain-graph-tab.tsx`, `react-force-graph-2d`): ~283 nodes (doc/blog/pSEO/founding/crawled sources + lesson + pattern-family nodes), edges from per-source embedding centroid kNN + lesson similarity + pattern-family membership (`getKnowledgeGraph` → `/api/admin/cmo/brain/graph`). Communities colour-coded, search-to-highlight, zoom-to-fit on load, click a node → detail rail + open/delete. Loaded via a client-side `import()` held in state (so the component's `ref` survives — `next/dynamic` drops it).
- Corpus — see/add/edit/remove: files (preview, delete source, force reindex) + lessons (inline edit → re-embed, pin-to-brief, delete, rebuild families) + "Add knowledge" (paste → distill). New `brain-actions.ts` + `listLessons`/`editLesson`/`deleteLesson`/`setLessonPinned`/`deleteKnowledgeSource` in `adapters/knowledge.ts`.
- Prompt — a runtime, layered, revertible override of every persona section (persona / operating-procedure / brief-contract / chat-protocol / appended strategy-note) over the hardcoded code defaults, with version history + one-click revert + a dry-run Preview brief (`previewBrief`) so a bad prompt is caught before it activates.
- Config — model (brief/chat), temperature/tone sliders, retrieval toggles (heading-boost, lesson-digest size).
- Foundation: a new `cmo.settings` (singleton) + `cmo.prompt_versions` store (migration `0034`) read by `loadBrainConfig()` (cached ~30s) and merged as `override ?? CODE_DEFAULT` into the brief, chat, model resolver, temperatures, and `searchKnowledge` title-boost — so an empty store reproduces prior behaviour exactly and every change is revertible. Admin-gated; verified end-to-end in-app; retrieval eval held at recall@10 0.96 / nDCG@10 0.82 throughout.
- CMO "Moxxie" — data-derived brain overhaul (2026-06-14). Reworked the AI CMO so the prompt can no longer be the bottleneck that ships the wrong strategy. De-hardcoded the strategy verdict in `persona.ts` (a baked-in "the bottleneck is eyeballs, not conversion" line was removed) and added a shared `OPERATING_PROCEDURE` (Plan → Gather → Diagnose → Decide → Self-critique) so Moxxie derives the binding constraint from live data every run. The daily brief is now hybrid-agentic (`brain.ts`) — it deterministically gathers the funnel + product state, then runs a bounded AI-SDK tool loop before emitting the brief. Six new read tools via the ports/adapters seam (`adapters/metrics.ts`, `tools/read-tools.ts`): `getGrowthFunnel` (activation/signup→paid/paywall/retention/margin + 13-wk trend), `getRevenueBreakdown`, `getProductState` (live health + recently-shipped + plan limits), `getContentQuality`, `getFeatureAdoption`, `getLlmCostByFeature`. Learning loop (`learning.ts`) — `getBriefHistory` + `getPastDecisions` + a gated `recordDecisionOutcome`, and the brief now reflects on whether the prior bottleneck moved. CMO→content link — Moxxie's directives steer the content agents (brief/draft/humanize), pause holds the pipeline, and sub-bar pages are flagged at review (`content/agents/*`). Sage (support assistant) gained KB search, billing/usage, full transcription history, prior tickets, and live product status (`support/admin-queries.ts`). Grounded in a new curated GTM playbook corpus (`project_docs/gtm-playbook/` — stage playbooks, sourced SaaS benchmarks, channel fit, decision rubric) + the previously-unindexed `docs/*` ops/strategy docs, all RAG-embedded. Full technical doc: `project_docs/cmo/ARCHITECTURE.md`.
- CMO knowledge retrieval — eval harness + heading-aware boost (2026-06-14). Added a retrieval eval harness + golden query set (`scripts/cmo/eval-retrieval.ts`, `project_docs/cmo/eval/golden-queries.jsonl`) reporting Recall@k / Precision@k / nDCG@k / MRR — the tripwire that gates retrieval changes. Baselined the dense index (recall@10 0.92, nDCG@10 0.77), measured that naive hybrid lexical fusion regressed on our corpus (Postgres FTS has no IDF), and instead shipped a zero-dependency, no-new-infra heading-aware boost in `adapters/knowledge.ts` — re-ranks the dense candidate pool by `cosine + heading/filename term-overlap` (default on; `RAG_TITLE_BOOST=0` reverts). Lift: recall@10 0.92→0.96, nDCG@10 0.77→0.83, MRR 0.77→0.85 with no regression. The full enterprise-RAG plan (hybrid + third-party reranker + CI eval gate) is specced but deliberately deferred behind explicit triggers (`project_docs/cmo/RETRIEVAL_SPEC.md`).
- CMO "Moxxie learns" — distill crawled articles into atomic, deduped lessons (2026-06-14). The crawler (URL → crawl4ai → `crawled_documents`) can now feed Moxxie a growing library of external growth/SEO/marketing tactics without dumping whole articles into context. New `distillLessons()` runs one bounded Ollama (kimi-k2.6, quality tier) call per article and extracts 3–8 atomic, founder-actionable lessons (tactic/framework/warning, each categorized + confidence-rated + source-attributed), dropping vendor-pitch / self-promo lines (verified: 5 okara.ai articles → 28 lessons, zero "use Okara" survivors). `ingestCrawledDocLessons()` embeds each lesson and upserts it as a tiny `source_type='lesson'` chunk (new `knowledge_chunks.metadata` jsonb, migration `0033`). Corroboration-aware merge (`upsertLessons()`): a near-identical tactic from another article (cosine ≥ 0.78) is merged into the existing lesson — its source is added, a `corroboration` count bumped, confidence maxed — instead of dropped, so "N independent sources back this" becomes a real signal Moxxie weights (idempotent per doc by `source_id`). Pattern families (`rebuildLessonFamilies()`): a union-find clustering pass (cosine ≥ 0.72) groups _sibling_ tactics and synthesizes one `kind='pattern'` meta-lesson per family naming the unifying principle (`synthesizePattern()` — e.g. Dropbox referral + Canva watermark + Figma links → "build distribution into the product's core action"). Surfaced two ways: RAG (the `searchKnowledge` tool gained a `lesson` corpus) and a hard-capped "RECENTLY LEARNED" digest (most-corroborated first) in the daily brief (`getRecentLessons()` → `brain.ts`). `persona.ts` frames lessons as advisory, source-attributed, corroboration-weighted, and subordinate — cross-checked against live data + the founding strategy, never overriding internal priors. Admin promotes via a "Distill to lessons" button on the crawled-doc viewer (`distillCrawledDocToLessons`) + a rebuild-families action. Eval-gated ("always better, never worse"): the 25 internal-doc golden queries hold (recall@10 0.96, nDCG@10 0.82) with 26 seeded okara lessons + 3 pattern meta-lessons in the pool, while a new 10-query tactic set (`golden-tactics.jsonl`, run via `eval-retrieval.ts --set=`) surfaces a relevant lesson at recall@5 1.00. Seed tool: `scripts/cmo/seed-lessons.ts`.
- Flippable scaling-day calendar — admin stats + quote as of any past day (2026-06-13). The `/admin` hero desk-calendar now flips back (page-curl corners) to any earlier scaling day and shows the 8 headline stats, the quote, and the date as they were that day — from Day 100 you can look back at Day 77. New `portal_stat_snapshots` table + daily `PORTAL_STAT_SNAPSHOT` pg-boss job (`src/lib/queue/portal-stat-snapshot-job.ts`, 03:35 UTC, just after the MRR snapshot) capture every stat + the day's quote point-in-time, kept forever. History is available immediately via `scripts/backfill-portal-stat-snapshots.ts` (run locally, needs `.git`), which reconstructs each day since Day 1 from source tables — users/transcriptions/minutes/published by row timestamps, MRR from `mrr_snapshots`, site ping from `health_check_daily` (`monitor_key='api'`), and codebase LOC by replaying git history. UI: `DeskCalendar` gains flip controls, `ScalingDayCard` a historical "⟲ Rewind" mode, and a new `ScalingDayDashboard` client wrapper owns the selected-day state, fetches `GET /api/admin/portal-stats/history?day=N`, and swaps the whole hero (the calendar's number-flip animation fires on day change). Today stays live (`force-dynamic`); `resolveScalingQuoteForDay()` exposes the quote pick without the `last_shown_at` bump for the job + backfill.
- Support module — 2-pane inbox, DiceBear personas, design-system pass (2026-06-13). Round 2/3 polish on the support admin to bring it in line with the rest of the app.
- Inbox as a real 2-pane shell (`src/components/admin/support/inbox-layout.tsx`) — thread list on the left, conversation canvas on the right, selection kept in the URL as `?c=ID` (back/forward survives). `/admin/support/[id]` deep-links (Slack/email CTAs) now redirect into the canvas so the inbox stays the single entry point. Rich empty state (brand-bloom hero + Preview/Ask Sage/Settings CTAs) when zero threads. Tailwind JIT silently dropped the arbitrary `grid-cols`/`h-[calc(...)]` values here — bypassed with a scoped `<style>` block + inline `style` so the layout renders deterministically.
- DiceBear personas across the module (`src/components/admin/cmo/agent-persona.ts`). Sage (support assistant) → warm-female `adventurer` face on a pink→amber gradient disc that blends with the dark surface (seed `Sage` locked after visual verification — variant lottery can otherwise land on a scowl). Tosh (founder fallback) → confident dark-skin face with smart glasses on a violet→pink gradient (seed `Founder` locked; hair palette has no green entries, mouth variants are all neutral/smiling, glassesProbability=100). New `userAvatar(seed)` for real customers — neutral slate/teal/indigo palette so users stay visually distinct from internal personas, deterministic by `user-{id}` / guest email / conversation id. `FounderAvatar` now falls back to the Tosh DiceBear when `/founder/headshot.jpg` 404s instead of rendering initials.
- Design-system pass across all support pages — brand-gradient `SectionCard` containers, gradient toggle switches + mode cards on Settings, browser-mockup widget preview with 3-column config grid on Preview, centered Moxxie-style "Ask Sage" chat on Assistant with per-suggestion icons (ListChecks / Wand2 / Search / AlertCircle) replacing the generic Sparkles. Support thread bubbles (`support-thread.tsx`) use brand-gradient backgrounds; the AI draft card swaps Sparkles for Wand2. Visible loading skeletons (matching tokens, not the previous invisible `bg-white/[0.03]`).
- Support — founder-channel chat across the app, AI triage, reply-by-email (2026-06-13). Two-tier support: an in-app widget (signed-in, prioritized by subscription tier) + a public-marketing widget (anonymous, rate-limited + honeypot + email-gated), with a founder-branded "Chat with Tosh" bubble across both surfaces (`src/components/support/support-widget.tsx`, mounted in `src/app/layout.tsx` + `src/app/dashboard/layout-content.tsx`). Data lives in a dedicated `support` Postgres schema (`src/lib/support/db/schema.ts`, pgSchema isolation mirroring CMO) — `support_conversations`, `support_messages`, `support_settings`. Conversations sort by tier (paid > trial > free > anonymous) via `resolveEffectivePlan` (`src/lib/support/tier.ts`).
- Admin module at `/admin/support` — sidebar entry with a DiceBear assistant avatar ("Sage", `src/components/admin/cmo/agent-persona.ts`) placed right after CMO. Sub-pages: Inbox (rich empty state with live widget status), conversation thread with reply box, Settings (widget config: founder name, greeting, away message, on/off toggles), Widget preview (renders the actual customer widget from config), and Support Assistant — a Moxxie-style chat where you ask the AI to draft, look up a user's failed transcriptions, or summarize the queue.
- AI triage (`src/lib/support/agent.ts`, reuses the CMO Ollama Cloud / kimi-k2.6 model via AI SDK). Per-conversation read tools scoped to the conversation's user (`src/lib/support/tools/read-tools.ts`): `getRecentFailedTranscriptions` + `getUserContext`. Run on demand via "Process with AI" — tags the conversation, drafts a reply in the founder's voice, shows the looked-up failures, and bumps inbox priority on `billing` / `bug` / `transcription_failure`.
- Away-mode auto-reply (`src/lib/support/auto-reply.ts`). Global AI mode `off | draft | auto` (+ per-conversation override). In `auto`, the agent auto-sends only when (a) confidence ≥ threshold (default 80) AND (b) category is in a safe allowlist (faq / how-to / status); billing, refunds, bugs, account, data issues always escalate to the founder. Disclosure-by-author: founder replies render as the founder (no AI badge, even if AI-drafted); only fully-unattended auto-sends are stamped `author='ai_assistant'` and show the "Wisprs Assistant" badge in the widget and the email signature.
- Notifications via the existing event pipeline (`src/lib/events/types.ts`, new `support_message_received` + `support_reply_sent`). Slack alert to the founder on incoming messages (tier emoji, snippet, deep link to `/admin/support/[id]`); Resend "founder replied" email to the user with quote + CTA — gate extended in `src/lib/events/providers/resend.ts` (the gate is `transcription_*`-only by default, easy to miss). Per-message idempotency keys prevent duplicate sends.
- Reply-by-email (Resend inbound) (`src/app/api/webhooks/support-inbound/route.ts`, `src/lib/support/reply-address.ts`). Outbound emails carry `Reply-To: c{id}.{token}@reply.wisprs.com` where token = HMAC-SHA256 of the conversation id (no DB column; unguessable without `SUPPORT_REPLY_SECRET`). The inbound webhook verifies the svix signature, fetches the body via `GET https://api.resend.com/emails/receiving/{id}`, strips quoted history + signatures, filters auto-replies/out-of-office, dedupes by Resend `email_id`, and appends as a `user` message — the thread stays alive whether the user replies in the widget or straight from Gmail. Operator setup: `devops/SUPPORT_INBOUND.md` (MX on `reply.wisprs.com` + Resend inbound domain + 3 env vars).
- Anonymous public threads keyed by an httpOnly retrieval-token cookie (`src/lib/support/token.ts`, only the SHA-256 stored as `guest_token_hash` — token possession is the auth, email is never an auth factor). Anti-spam: per-IP rate limit (reuses `checkFreeToolsRateLimit`) + honeypot field + email validity gate (`src/lib/support/anti-spam.ts`). Migrations: `0025_colorful_raider` (schema + tables) + `0026_support_module` (adds `support_settings.config` jsonb for widget settings).
- Free-tools abuse hardening — caps, dedup, byte budget, stuck-job reaper, admin kill/retry (2026-06-13). Recon found the public free tools were unguarded: one anonymous requester pulled the same 501 MB YouTube livestream 5× (2.2 GB = 99.6% of all bytes the downloader had ever moved), and two `free-audio-to-text` jobs sat `processing` for 11 and 30 days (pg-boss kills its queue job at the 12h deadline but never wrote status back, and there was no reaper). Five guards added in `src/lib/free-tools/`: (1) download caps — yt-dlp `--max-filesize` (200 MB) + `--match-filter "!is_live & duration<=3600"` + `--break-match-filters` reject oversized/over-long/livestream sources before the bytes are spent, with a post-download size backstop (`providers/caps.ts`, applied to youtube + tiktok args/providers); (2) per-URL dedup — `normalizeMediaUrl()` collapses watch/`youtu.be`/`/live/`/`/shorts/` forms to one key so a repeat request returns the live job instead of re-downloading (`normalize-url.ts`, `getLiveDuplicateToolJob`); (3) daily byte budget — 2 GB per requester per 24h → 429 on both submit routes, on top of the existing 50/hr per-IP+device limits; (4) stuck-job reaper — folded into the existing 30-min `free-tools-cleanup-worker` (`reapStuckToolJobs`): download jobs hard-fail past `FREE_TOOLS_STUCK_THRESHOLD_MINUTES` (120), `free-audio-to-text` jobs reconcile from their linked transcription — auto-closes the 11d/30d orphans; (5) admin controls — new Cancel/kill action for queued/processing jobs + Retry now reprocesses anonymous a2t jobs from their stored upload (`reprocessFreeAudioTranscription`). Four new clamped `FREE_TOOLS_*` env vars (`env.ts`).
- Historical MRR snapshots — real recurring-revenue trend (2026-06-12). New `mrr_snapshots` table + daily `MRR_SNAPSHOT` pg-boss job (`src/lib/queue/mrr-snapshot-job.ts`, 03:30 UTC) stamps point-in-time canonical MRR (`captureMrrSnapshot()` in `portal-metrics.ts`). The North Star weekly-MRR chart now reads the latest snapshot on/before each week-end — real recurring revenue, replacing the old trailing-30d-cash proxy. Past weeks were reconstructed once from subscription lifecycle (`created_at`/`canceled_at`) so the line is populated immediately. See `project_docs/METRICS_SOURCE_OF_TRUTH.md`.
- CMO Growth Engine — "Moxxie", the AI CMO (2026-06-12). A bounded, extractable admin sub-application (`src/lib/cmo/`) that harmonizes the already-running marketing automations (content/SEO pipeline, IndexNow, domain-authority, directories, cold-email, lifecycle) into one marketing org fronted by an AI CMO named Moxxie. Mounted at `/admin/cmo` as its own sidebar module behind the `GROWTH_ENGINE_ENABLED` flag.
- Command Center (`src/app/admin/cmo/page.tsx`) — Moxxie's daily brief (scannable priority tiles via native `<details>` + per-agent directives), a null-filtering KPI strip, and a department-health overview. Agents page (`src/app/admin/cmo/agents/page.tsx`) holds the full agent roster + unified activity feed; Calendar harmonizes the content + directory + daily-cadence timelines.
- The brain (`src/lib/cmo/brain.ts`, `persona.ts`) — daily single-flight Ollama (`OLLAMA_MODEL_QUALITY` / kimi-k2.6) synthesis grounded in ~32K chars of real strategy docs (`adapters/docs.ts`), strict-JSON validated, with a curated fallback so the page never breaks. Web-search/fetch utility added to Ollama (`ollamaWebSearch`/`ollamaWebFetch`).
- Tracking spine — `cmo.agent_runs` unified run log + `recordAgentRun()` wired into the content-calendar/IndexNow/domain-authority/email-sequence jobs; `CMO_ROSTER_SYNC` heartbeat (~15 min) rolls up health + flags overdue agents; `CMO_BRIEF_REFRESH` daily cron. The engine owns a dedicated `cmo` pgSchema (`src/lib/cmo/db/schema.ts`).
- Analytics ingestion — GA4 Data API + GSC Search Performance read clients (`ga4-data.ts`, `gsc-data.ts`, service-account JWT in `google-auth.ts`); a weekly `GSC_SNAPSHOT` job → new host `gsc_snapshots` table (fills the North Star organic-impressions placeholder); event-funnel helpers (free-tool / paywall / channel attribution) over `event_log` (`admin-queries.ts`).
- Hard module boundary — `@cmo/*` tsconfig alias + eslint `no-restricted-imports` rule: the domain core depends only on `ports/`, and host internals are imported only inside `adapters/`, so the engine can be lifted to its own service without rewrites. Roster faces are persona-styled DiceBear avatars (writers / creatives / ops-robots / leads + Moxxie) on brand-tinted discs.
- Status & uptime monitoring (2026-06-12). Public `/status` page (`src/app/status/page.tsx`) + admin status view, backed by three edge-triggered pg-boss health probes (every ~5 min): `BRIDGE_HEALTH` (STT bridge `/v1/health`), `API_HEALTH` (in-process DB/pg-boss/queues/bridge), and `THIRD_PARTY_HEALTH` (ElevenLabs/Polar/Clerk/Resend/Ollama reachability) — `src/lib/monitoring/` + `src/lib/queue/{api,bridge,third-party}-health-job.ts`. New `health_checks` / `monitor_state` / `health_check_daily` tables retain samples + daily rollups; edge-triggered Slack alerts on down/recovery only. Honest about its blind spot (in-process probes can't detect total app death) — documented in `devops/EXTERNAL_UPTIME.md`. Backfill: `scripts/backfill-status-uptime.ts`.
- Free tools admin — live home proxy monitor (2026-06-10). `/admin/free-tools` Egress proxy now probes `FREE_TOOLS_HTTP_PROXY` with the runbook §5 `curl` check (`https://www.example.com` via Squid/Tailscale), shows reachable/down/unset, latency, upstream HTTP status, and 24h yt-dlp fail rate; auto-refreshes every 60s via `GET /api/admin/home-proxy-health` (`src/lib/free-tools/home-proxy-health.ts`, `src/components/admin/home-proxy-monitor.tsx`). Documented in `devops/HOME_PROXY_SQUID_TAILSCALE.md` §8.
- Admin scaling welcome card (2026-06-10). `/admin` opens with a calendar-style Day X of scaling Wisprs hero — animated gradient border, time-of-day greeting, daily motivational quote, and count-up on load. Scaling Day 1 = Jun 11 2026 Africa/Nairobi (company day 200); pre-launch shows countdown. Integrations stat moved to a compact card bottom-right under Modules (`src/lib/admin/scaling.ts`, `src/components/admin/scaling-day-card.tsx`).
- Blog hero images — `board-meeting-transcription` + `content-creator-transcription` (2026-06-10). Cursor AI plates → image-only featured WebP; `heroImage` in frontmatter. Ship via `scripts/sync-images.sh blog` (nginx `/images/` alias on Contabo serves new files without rebuild).
- Directory submissions program — DB + admin dashboard (2026-06-10). New `directory_submissions` + `directory_submission_attempts` tables (migration `0015_classy_lilandra`) as the single source of truth for the seomade 974-directory index + TOS-7 manual entries. Admin analytics at `/admin/analytics/directories` (live listings, queue, cumulative live chart, attempt feed). Seed: `scripts/seed-directory-submissions.ts` + `project_docs/launch/directory-submission-kit/seomade-directories.json`.
- Domain Authority chart on admin content analytics (2026-06-10). `/admin/analytics/content` now shows latest DR snapshot + 52-week weekly series for wisprs.co vs competitor set (from `domain_authority_snapshots`).
Changed
- Unified metric definitions across the admin portal (2026-06-12). The home, North Star, CMO, subscriptions page, and daily Slack digest had grown three different definitions for the same headline numbers — most notably "MRR" meant _30-day cash collected_ on three surfaces and _active-subscription recurring revenue_ on two. New single source of truth `src/lib/analytics/portal-metrics.ts` defines each once: MRR = monthly-normalized active non-trial non-admin subscriptions (point-in-time, acquirer-correct); Net revenue (30d) = paid-order cash (renamed, no longer mislabeled "MRR"); Content live = total published `content_items` (never `content_jobs`); Users vs New signups (30d) kept distinct. All five surfaces now read from it, so the same word means the same number everywhere. Admin home MRR switches from 30d-cash to recurring; subscriptions page gains a separate "Net revenue (30d)" card; the digest's `mrr_milestone` bands now track recurring. Definitions: `project_docs/METRICS_SOURCE_OF_TRUTH.md`.
- Admin scaling card layout (2026-06-11). Daily quote moves to the right panel with a violet→pink→amber gradient separator; greeting and countdown stay left (`src/components/admin/scaling-day-card.tsx`).
- Admin home stats grid (2026-06-11). First row is now Users, MRR (30d Polar paid), Transcriptions, and Minutes transcribed (completed, all-time); Founded, Codebase, Site ping, and Published content follow. MRR + minutes pulled in `src/lib/admin/portal-stats.ts`; 4-column grid on large screens (`src/app/admin/page.tsx`).
- Admin home stats grid (2026-06-10). Five snapshot cards (Founded, Codebase, Site ping, Users, Published content); Integrations removed from top row.
- Trial policy → card-required only, uniform 3 days; no-card reverse trial parked (2026-06-10). Master kill-switch `TRIAL.enabled` (default false, `src/lib/billing/limits.ts`) darkens the no-card reverse-trial offers everywhere — `startTrial` refuses (`disabled`), the dashboard `TrialBanner` and AI paywall hide the "Start free trial" / "Unlock AI free" buttons (via `getMyTrialState.offerEnabled` / `getPlanUsageInfo.trialOfferEnabled`). The active model is now the card-required Polar trial, uniform 3 days on Studio + Agency (monthly + annual); Pro has no trial — set via the Polar API. The no-card infra stays built behind the flag for a future A/B test once trial volume justifies it.
- Trial-grant abuse alert. When the reverse trial is re-enabled, every `trial_started` posts a `:gift:` Slack alert with email + abuse signals (`has_fingerprint`/`has_ip`, `total_trials`, `same_device`, `same_ip_24h` via `getTrialAbuseSignals`); `trial_ended` posts too (`slack.ts`).
- Language detection on failed/cancelled transcripts (`stt-bridge/main.py` + `src/lib/queue/language-detect-backfill-job.ts`). New `POST /v1/detect-language` endpoint on the self-hosted Whisper bridge — probes the first 30s of audio and returns an ISO-639-1 code + confidence in ~1s. A new cron job runs twice daily (02:27 + 14:27 UTC), scans failed/cancelled transcripts in the last 30 days that have no language, calls the bridge for each, and writes back. First prod run filled in 24/24 previously-language-less rows (English failures dominate, with Spanish/Polish/German/Japanese/Norwegian-Nynorsk in the tail). Stamps `metadata.languageDetectedBy = 'post-fail-bridge'` for audit. Cost: ~$0 — reuses the existing bridge GPU.
- Internal-link allowlist fed to the content generator (`src/lib/content/agents/inventory.ts`, `src/lib/content/agents/suggest-internal-links.ts`, `src/lib/content/agents/draft.ts`). The draft agent used to operate on a permissive prompt that let it invent plausible-looking but non-existent paths (`/blog/podcast-transcription-guide`, `/use-cases/meeting-transcription`, etc.). Now the orchestrator loads a live inventory of every linkable Wisprs page (content/blog/_.md + content/pseo-programs/_.json + src/app/<family>/<dir>/page.tsx walks + the static leaf-route list), ranks the top-8 by Jaccard token overlap against the brief's keywords, and passes them as an explicit "internal-link allowlist" in the draft user prompt. The system prompt now explicitly forbids inventing sub-paths under family routes. Cache key includes per-file mtime so a freshly-published pSEO entity becomes link-eligible the same day.
- Link validator + audit/apply tooling (`scripts/triage/validate-links.mjs`, `scripts/triage/audit-broken-links.ts`, `scripts/triage/apply-link-fixes.mjs`). Standalone validator with two modes: offline (instant, structural — leaf-route allowlist + dynamic-family content-index + src/app walk) and live (HEAD-probes against wisprs.co). Wired into the editorial pass so any write that would introduce a broken link is blocked pre-flight. The audit script walks every blog md + every pSEO entity, emits a TSV the operator can edit, and the apply script does literal `](old) → ](new)` substitutions with post-validation. Documented as Step 8 in the pipeline-triage skill.
- "Learn more" sections on thin feature pages (`src/lib/features-data.ts`, `src/app/features/[id]/page.tsx`). `FeatureDetail.relatedContent` now lets a feature declare curated topical neighbors (title + url pairs); the template renders them as a "Learn more" list under the CTA. Populated for `/features/folders-organization` and `/features/batch-processing` (the two crawled-not-indexed feature pages from today's GSC drill); other features can adopt incrementally.
- Consistent table pagination across admin dashboards (`src/components/admin/admin-ui.tsx`). Extracted `<TablePagination>` from the deliverability page's canonical impl and applied to 12 admin tables (users, users/activity 3-tab, email/sends, email/sequences, cold-email/runs, cold-email/leads, cold-email/deliverability, subscriptions/active, transcriptions, analytics/events, analytics/queues, content). One style, one keyboard surface, "Showing N–M of T · ← Prev · page X/Y · Next →" with disabled-state styling everywhere.
- Admin transcriptions table now has a `Lang` column (`src/app/admin/transcriptions/page.tsx`). Shows language for every status — STT-detected rows immediately, failed/cancelled get filled in by the next backfill sweep.
Changed
- ISR per-slug invalidation in `content:sync` (`scripts/sync-content.ts`, `src/lib/content/sync.ts`). The sync script used to only purge `/blog` index prerenders, never the per-slug `/blog/<slug>` or `/<family>/<slug>` pages — so a content update needed a manual `rm` + `systemctl restart` to propagate. `syncContentToDb` now returns the list of slugs whose body bytes changed (`changedSlugs`) AND the list where the DB body was kept over file (`preservedDbBody`, for observability when `WISPRS_CONTENT_SYNC_BLOG_BODY_MODE=preserve-richer-db` is in effect). The script iterates `changedSlugs` and removes the matching `.next/server/app/<route>/<slug>.{html,rsc,meta}` files. Maps from slug → route covers blog, the seven known pSEO families (`/tools`, `/alternatives`, `/podcast`, `/use-cases`, `/speech-to-text`, `/integrations`, `/free-tools`), and the `core-software.json` bare-slug catch-all (`src/app/[slug]/page.tsx`).
- STT language codes normalized to ISO-639-1 at every write site (`src/lib/transcription/normalize-language.ts` + 5 call sites in `src/lib/ai/*` and the EL webhook). ElevenLabs returns ISO-639-2/3 (`eng`, `hin`, `spa`…); OpenAI + self-hosted return ISO-639-1 (`en`, `hi`, `es`…). Result: the admin "Top languages" chart had `eng` + `en` as separate rows. Added a single 44-entry mapping (bibliographic + terminological forms covered) with 7 unit tests, idempotent, locale-aware (`en-US` → `en`). Preserves unknown 3-letter codes for debug rather than silently dropping. One-shot backfill ran on prod against 2 historical rows (`hin` → `hi`, `eng` → `en`).
- Admin "Top languages" chart filters to completed-only (`src/lib/analytics/admin-queries.ts`). Was grouping over all statuses, so the 18 failed + 2 cancelled rows (which have empty language because STT never ran) rolled into a fake "unknown" bucket making it look like our language detection was broken. Now `WHERE status = 'completed' AND language IS NOT NULL`. Renderer fallback relabelled `'undetected'` (instead of `'unknown'`) so a real detection failure on a completed row is visibly distinct.
- Free tier harmonized to "30 minutes / day" everywhere. Free is enforced as a daily cap (`minutesPerDay: 30`, UTC-midnight reset) but several surfaces showed the legacy monthly 60. Now consistent across the dashboard pricing "Current Plan" card, sidebar, billing settings, usage panel (the phantom monthly bar is dropped for free), public pricing cards/FAQ/homepage, sales pages, and the founding pricing doctrine. Copy-only — no enforcement change.
- Upgrade CTAs are trial-aware everywhere via `upgradeCtaFor()`: never-trialed → "Upgrade to Pro ($25)", mid-trial → "Keep Studio", trial-ended → "Get Studio back ($79)". Applied to the cap-hit modal, inline CTA, quota banner, usage panel, and the sidebar button.
- AI-feature paywall (`transcript-tabs.tsx`) now starts the real no-card Studio reverse trial inline instead of a fake "3-day free trial" that linked to Pro.
Fixed
- Empty-audio transcriptions no longer falsely "completed" — labeled "No speech", 0 minutes charged (2026-06-13). A bridge result with no text + no segments (e.g. 8s of wind, no speech) was being written as `status='completed'` with a null duration and empty transcript — a false positive that also implied a usage charge. The bridge poller now detects an empty result and routes it through `finalizeNoSpeech` (`src/lib/queue/bridge-completion-poller.ts`): `status='failed'`, `metadata.outcome='no_speech'`, `minutesCharged=0`, a clear user message, and deliberately no ops false-alarm (skips `recordBridgeJobFailed`). The user transcript list and admin list + detail render it as a calm amber "No speech" badge instead of a red "Failed" (`transcriptionBadge()`/`isNoSpeechTranscription()` in `src/lib/transcription-status.ts`). Two existing false-positive rows were re-labeled on prod.
- Polar card-required trial removed from Pro (no trial on Pro). (Studio's Polar trial was briefly removed on 2026-06-08 in favor of the no-card reverse trial, then re-enabled 2026-06-10 as a 3-day card trial — see the Added entry above for the final state.) Done via the Polar API; affects new checkouts only.
- Duplicate `usage_tracking` rows — concurrent get-or-create at subscription-start raced and inserted duplicates per period. Added a unique index on `(user_id, period_start)` + onConflict upserts in `usage.ts`; deduped existing prod rows.
- Admin "current period usage" read the stale calendar-month row (showed 31/1 instead of 98/4); now orders by `createdAt` to pick the current period (`admin-queries.ts`).
- `trial_grants` prod permissions — the table (owned by `postgres` via a live hand-patch) lacked grants for the app role `wisprs`, so the reverse trial threw. Fixed with `GRANT` (see `docs/SOURCE_OF_TRUTH.md`).
- Deploy migrate wedge — `wisprs-deploy`'s `db:deploy` aborted under `set -e` when migrations weren't recorded in `__drizzle_migrations`, so code never reached prod. Made `0009`/`0011`/`0012` idempotent + reconciled the tracking table.
Added
- Reverse trial — no-card, metered "Studio for 7 days." A self-serve trial that layers a paid effective-plan on top of `role='user'` with no Polar subscription (no charge, no refund surface); when it ends the user lands back on Free. New chokepoint `resolveEffectivePlan()` is now the single source for plan resolution — the gate, STT-tier routing (`src/app/actions/transcriptions.ts`), feature flags, and usage display all read it, so a trial user routes to the priority/ElevenLabs engine automatically.
- Two kill-switches, whichever trips first: clock (`trialExpiresAt`, 7 days) and meter (`trialPremiumMinutesUsed >= cap`). The meter — 150 premium STT minutes + 10k premium TTS chars, decremented atomically in `gate.ts` / `tts.ts` (mirrors the bonus pool) — is the ElevenLabs COGS bound (≈ <$1 worst-case per account), protecting the 60–80% blended-margin doctrine. The clock is not the protection. Liveness is computed lazily, so the sweeper is never needed for correctness.
- Iron-clad anti-abuse: one trial per human, ever — `startTrial()` + new `trial_grants` ledger (unique email index = race-proof) plus fingerprint/IP-ring dedup, disposable-domain block, OAuth/verified-email gate, and first-transcription activation gate. All layers fail closed (deny → stay on Free; no harm).
- Lifecycle: `trial_started` enrolls the new `trial` email sequence (started / ending / ended — `src/emails/trial-0.tsx` …`trial-2`); hourly `trial-sweep` job stamps ended trials + fires `trial_ended` analytics; conversion clears the trial via `markTrialConverted` in `sync-subscription.ts`.
- UI: dashboard `<TrialBanner />` offers the trial post-activation (no card), shows live days/minutes remaining, and nudges post-trial. Public pricing page intentionally untouched (grant is activation-gated, so the offer lives in-product). Pro classic trial intentionally NOT shipped — at $25 card-friction suppresses starts; classic card-required trials are reserved for Agency/Enterprise.
- Config dial: `TRIAL` in `src/lib/billing/limits.ts`. DDL via `db:push` (artifact: `docs/payments/reverse_trial.sql`). Docs: `docs/SOURCE_OF_TRUTH.md` → "Reverse trial". Operator steps: Clerk verified-email/OAuth config + optional client fingerprint lib. Unit-tested (`effective-plan.test.ts`).
- Admin Portal home stats row. `/admin` shows six snapshot cards: founding date (first tracked commit), codebase LOC (`src` / `content` / `scripts` / etc.), live `GET /api/health` ping to prod (`NEXT_PUBLIC_APP_URL`), user and transcription totals, published `content_items`, and integration + Postgres status. Loaded server-side via `src/lib/admin/portal-stats.ts` with short-lived cache for LOC and site ping.
- Admin content library chart: Recharts time-series on `/admin/content` for published, calendar-scheduled, and generated volume by content type (blog, tool, landing, use-case, etc.) with range presets and cumulative/daily modes.
- Admin content calendar — historical backfill. `/admin/content/calendar` now overlays all published `content_items` on their actual `publishedAt` dates (calendar TZ), extending the grid back to the earliest live publish (e.g. May 2026 and seed blogs from 2024). Manifest plan slots still show for unscheduled work; slugs already published appear only on their go-live day, not on a later manifest day. Live cards get a Live chip and public links when there is no queue job. Stats: days with content, manifest slots, live published, pending review.
- North Star metrics dashboard. New `/admin/north-star` page under the Home submenu with 11 live metrics across Revenue (MRR, active paid, ARPU), Acquisition (signups, conversion rate), Activation (activated users), Engagement (WAU, DAU/MAU, minutes), Retention (D7), and Velocity (R:D7). Timeseries chart with 7d/30d/90d/6mo/12mo range presets and toggleable MRR/signups/WAU/minutes series. All queries use raw SQL on existing tables. Two placeholder cards for the uncaptured metrics (daily-cap upgrade CTA clicks, organic search impressions). Query module: `src/lib/analytics/north-star-queries.ts`.
- Synced `.opencode/skill/` with `.claude/skills/`. Imported all 39 skills — 16 were already at parity, 23 newly copied. `.opencode/skill/` is now fully in sync with `.claude/skills/`.
Fixed
- Pre-validator broken internal links across 11 pages backfilled (commit `be0d372`). A one-shot audit of every blog md + pSEO entity surfaced 11 broken `](/path)` links across 9% of the corpus — historical artifacts from the old draft prompt that let the LLM invent plausible-looking sub-paths. Fixed in-place (e.g. `/blog/how-to-transcribe-audio-to-text` → `/blog/getting-started-with-audio-transcription`, `/dashboard` → `/sign-up`, `/use-cases/youtube-video-transcription` → `/tools/youtube-video-to-text`). Validator now enforces 0 broken links going forward.
- "Crawled - not indexed" content orphaning on 8 blog posts (commit `17068ad`). Today's GSC drill found 16 URLs in the Crawled-not-indexed bucket; 11 were real content pages. Audit revealed 8 of 9 blogs had zero internal links in the body (the 9th had 6 only because it was edited that morning). Content quality + word counts + structure were all adequate — the dominant negative signal was orphan content. Added a curated "Related guides" section with 4 internal links each to topically-adjacent blogs / alternatives / tools. Required `WISPRS_CONTENT_SYNC_BLOG_BODY_MODE=prefer-file` because the default `preserve-richer-db` kept stale DB versions despite larger file bodies (root cause of the propagation gap is fixed in the sync-content per-slug ISR purge above).
- Recurring deploy script auto-restart misses (logged, not fixed yet). The deploy script's final `systemctl restart wisprs` step intermittently doesn't fire — caught it 4× this session by checking service uptime vs deploy time. Workaround: restart manually after each deploy. Worth a follow-up dig.
- `indexnow_runs.urls` column missing on local + prod (logged, not fixed yet). Every IndexNow sweep throws `column "urls" of relation "indexnow_runs" does not exist` on the post-submit insert; the POST itself succeeds. Pending drizzle migration / `db:push`.
- GSC "Excluded by 'noindex' tag" bucket (11 URLs) was a self-healing false alarm — every URL now serves `meta robots: index, follow` at source (stray tag was already removed in a prior deploy). Clicked Validate fix; bucket will clear on Google's next crawl.
- Admin calendar status badges: `pending-review` no longer wraps to a tall pill on narrow cards; `size="sm"` uses compact labels (Review, SEO, Humanize) with `whitespace-nowrap` and full status in `title`/`aria-label`.
- Admin user growth chart: Recharts `ResponsiveContainer` with `width="100%"` rendered nothing on `/admin/users` until client width was measured. Chart now mounts after paint with skeleton, `min-w-0` wrapper, and `initialDimension` hardening.
Added — 6-month GTM plan execution (2026-06-04)
Strategy doc: project_docs/strategy/GTM_6MONTH_PLAN.md. 1,000 right-fit signups in 6 months at ~10% paid conversion. Locked ICP: Segment-A podcasters (9,657 verified) + Segment-C agencies (95K). Concentrated on 4 channels (cold email, pSEO/AEO, free tools, launch burst); explicitly killed paid ads, Twitter/LinkedIn, AE/SCK wedge, Enterprise for this window.
Week 1 — activation fixes (the math-killer, all 4 shipped):
- Fix A: contextual upgrade CTA when transcription cap is hit. `src/lib/billing/gate.ts` now returns a structured `limitHit: 'daily' | 'monthly'` flag with rewritten copy that names the plan + price; both `src/app/actions/transcriptions.ts` and the free-tool import action propagate it; new `<InlineUpgradeCta />` component renders the Pro CTA + ROI line under the upload-error block (linked to `/pricing?from=cap&limit=daily` for analytics). Previously the cap modal had no upgrade path — funnel-recon identified this as the cause of the ~5% conversion ceiling. (commit `10e2075`)
- Fix B: 80% quota banner on dashboard. New `<QuotaBanner />` server component rendered at the top of the dashboard; triggers at ≥ 80% of the daily cap (Free) or monthly cap (Paid) with tier-appropriate copy. `getPlanUsageInfo` extended with daily usage + allowed export formats. (commit `a1f33ab`)
- Fix C: export-format Pro gate w/ inline upgrade CTA. `src/components/transcript-actions.tsx` fetches the user's plan on mount, marks Pro-only export formats (VTT/DOCX/JSON) with a lock icon + "Pro" pill in the menu, and on click routes to `/pricing?from=export&format={fmt}` instead of failing silently. (commits `f96b1cc` + `94e6701`)
- Fix D: founder-voice 1-question welcome email. Rewrote `src/emails/welcome-0.tsx` from a feature pitch ("Wisprs turns audio into clean, structured text…") to a 1-question opener ("Quick one before you dive in: what are you trying to transcribe this week?"). Opens a reply loop (early-cohort replies correlate 3–5× with conversion) AND qualifies the ICP (the answer tells us podcaster/agency/AE). Subject downshifted to lowercase "quick question". (commit `576d7de`)
Expected total uplift: +6–9pp → moves the funnel from ~5% → ~11–14% conversion.
Week 2–4 — AI-personalized cold-email openers. Channel A's highest-leverage move per the GTM plan. Generates a per-lead 1-line opener from IG bio + category + segment via Ollama Cloud (`gpt-oss:120b-cloud`); stored in `cold_leads.metadata.opener` and pushed to Mautic as the custom field `wisprs_opener` so E1's body reads `{contactfield=wisprs_opener|templated fallback}`. Recipients see a sentence that proves we actually looked, not a `{{firstName}}` swap. Expected 2–3× reply rate over baseline templates.
- New: `src/lib/cold-email/personalize.ts` (gpt-oss-mini-ish prompt, segment-aware, graceful fallback when bio is too thin).
- New: `scripts/leads/personalize-openers.ts` (`npm run leads:personalize`) — env-tunable (LIMIT, SEGMENTS, OVERWRITE, CONCURRENCY, DRY_RUN), idempotent.
- `scripts/leads/push-to-mautic.ts` now reads `metadata.opener` and pushes `wisprs_opener` alongside `wisprs_cl`.
- `docs/outreach/SEQUENCES.md` + `docs/outreach/INFRA_RUNBOOK.md` updated with the new E1 body + pipeline-order (6b: personalize before 7: push-mautic) + Mautic custom field setup.
- (commits `f82e38f` then `84c12c0` swapping the provider — see below)
Week 5–6 — `/speech-to-text/{language}` pSEO matrix. Channel B's new template family (TurboScribe's highest-ROI play). New `TemplateKey` `speech-to-text-lang-v1` + `RouteFamily` `speech-to-text` + builder in `src/lib/content/pseo.ts` (language-specific intent in the lead paragraph, accents/dialects + auto-detect proof points, 100+ language hook). New route at `src/app/speech-to-text/[slug]/page.tsx`, hub path resolution + admin queue "Open page" extended. Deterministic generator in `scripts/seo/generate-speech-to-text-languages.ts` (`npm run content:generate-speech-to-text-languages`) ships v1 with 30 top languages (Spanish, French, German, Mandarin, Hindi, Arabic, etc.) at `content/pseo-programs/speech-to-text-languages.json`. Cross-listed in `90DAY_PSEO_MASTER_PLAN.md`. Phase 2 can scale to 100+ languages or add intent suffixes without code changes. (commit `55a4297`)
Week 7–8 — Transcript Quality Analyzer free tool. Channel C's #1 new free tool — both an SEO surface for "transcript quality / accuracy checker" queries AND a comparison weapon ("this looks like Otter: 87/100. Try Wisprs free."). New server action `src/app/actions/free-tools/analyze-transcript.ts` — Ollama Cloud (`gpt-oss:20b-cloud`, temp=0.3) scores any pasted transcript 0–100 across 4 dimensions (formatting, clarity, completeness, speaker handling) + best-guess at the source tool (Otter/Descript/Rev/Whisper/…). New page at `/tools/transcript-quality-analyzer` with a paste textarea + client at `transcript-quality-analyzer-client.tsx` rendering an overall score, per-dimension bars, AI summary, concrete-issues list, and a Pro CTA below the result with the score baked into the UTM (`?utm_source=tqa&utm_campaign=free_tool&score=87&likely=Otter`). No DB writes, no file upload, no job queue — stays free at any volume; heavy users self-select to sign up. Below-the-fold copy explains the scoring rubric for organic discovery. (commits `bdeec22` then `84c12c0`)
Week 9–12 — GEO/AEO sweep on money pages (Princeton GEO methods). Channel B's biggest moat-builder. Applies the 3 Princeton GEO levers (cite-sources +40%, statistics +37%, FAQPage schema +40% AI visibility) to existing pSEO entity bodies for AI-answer citation share.
- New `src/lib/content/geo-augment.ts`: pure, idempotent helpers — `addStatsCallout(body, stats)` inserts a "## Quick stats" block after the lead paragraph; `addSources(body, sources)` appends a "## Sources" block at the bottom; `hasStatsCallout` / `hasSources` guards.
- New `scripts/seo/geo-augment-pseo.ts` (`npm run seo:geo-augment`): batch runner. Calls Ollama Cloud (`gpt-oss:120b-cloud`, temp=0.5 to reduce hallucinated citations) per entity asking for 3 real stats + 3 real sources. Strict prompt: "NO INVENTED NUMBERS. NO MADE-UP URLs. Return fewer if you genuinely don't know." Idempotent (skips already-augmented entities unless `OVERWRITE=1`). `DRY_RUN=1` to preview without writing. `LIMIT` defaults to 8 for review-friendly diffs.
- The FAQ-schema lever (+40%) is already auto-emitted by `src/lib/content/seo.ts` whenever a body contains an FAQ section → 48 of 56 entities already covered.
- Output requires human review before deploying (AI-generated citations need verification). Script prints an explicit reminder at the end. (commits `d8ca795` + `c962fe5` then `84c12c0`)
AI provider — Ollama Cloud. Swapped the three GTM-plan AI callsites (cold-email personalize, GEO augment, transcript analyzer) from OpenAI `gpt-5-mini` to Ollama Cloud's open-weight `gpt-oss` cloud models. Lower cost, no OpenAI lock-in, same quality envelope.
- New `src/lib/ai/ollama-cloud.ts`: thin Bearer-auth client against `https://ollama.com/api/chat` (the OpenAI-compat endpoint is localhost-only as of 2026-06). Single `ollamaChat({model, prompt, system, temperature, maxTokens, timeoutMs})` function with token-usage return.
- `OLLAMA_API_KEY` added to `src/lib/env.ts` as optional.
- Model picks (overridable via `OLLAMA_MODEL_QUALITY` / `OLLAMA_MODEL_FAST`): `gpt-oss:120b-cloud` for personalize + geo-augment (quality drives downstream conversion / citation accuracy); `gpt-oss:20b-cloud` for the Transcript Quality Analyzer (lightweight scoring task on every paste).
- (commit `84c12c0`)
Strategy & supporting docs:
- New `project_docs/strategy/GTM_6MONTH_PLAN.md`: authoritative strategy + scoreboard.
- Existing `project_docs/strategy/COMPETITIVE_MOAT_STRATEGY.md` and `project_docs/strategy/COMPETITOR_PAGE_TRACKER.md` (earlier this session) ground the AEO moat thesis.
- `project_docs/pseo/90DAY_PSEO_MASTER_PLAN.md` updated with the new `speech-to-text-lang-v1` template family.
Added
- Clerk webhook endpoint configured + `CLERK_WEBHOOK_SECRET` set on prod. The handler code already existed at `src/app/api/webhooks/clerk/route.ts` and processes `user.created/updated/deleted` + `session.created/ended` (svix signature verification), but no Clerk dashboard endpoint had ever been created — so `auth_sessions` was empty and `sign_up` / `sign_in` events never fired. Endpoint added in Clerk → Webhooks (production instance) pointing at `https://wisprs.co/api/webhooks/clerk` with the 5 events subscribed; signing secret set on `/opt/apps/wisprs/.env.production` and the service restarted. Logins + signups now flow to the DB AND through `trackEvent` → GA4 + Mixpanel.
- Admin portal — first cohesive admin surface, with email engine module. New `/admin/*` route group (src/app/admin/layout.tsx) gated by `requireAdmin()` (Clerk role='admin' OR `ADMIN_API_TOKEN`). Designed to host multiple modules — Email is first, Content/Users now also live (migrated below), Subscriptions/Analytics/Settings scaffolded as "soon". Email engine module is 5 routes: `/admin/email` overview (sequences, send volumes, recent sends), `/admin/email/sequences` per-user state with filters, `/admin/email/sends` chronological send log, `/admin/email/templates` live-preview iframe of every lifecycle template via `/api/admin/email/preview`, `/admin/email/users/[id]` user drill-down + manual controls (pause / resume / skip step / unsubscribe / resubscribe / enroll-in-sequence — all server actions verified inline with `requireAdmin()`). Shared queries in src/lib/email/admin-queries.ts; admin UI primitives (PageHeader, StatCard, Section, DataTable, Pill, timeAgo) in src/components/admin/admin-ui.tsx.
- Lifecycle email engine — contextual activation + paid-conversion drip. State-machine email system where each user is in exactly ONE active sequence at a time. Lifecycle events (`sign_up`, `activated`, `paywall_hit`, `subscription_started`) hit onLifecycleEvent (now wired into dispatch.ts alongside the other providers), which upserts `user_email_state` — state transitions overwrite the row, cleanly cancelling the prior sequence. The new email-sequence-job.ts runs every 30 min, finds users with `next_due_at <= now`, runs `skipIf` gates (`is_activated` for welcome, `is_paid` for upgrade flows) against fresh DB state, renders the React Email template via the typed registry in render.ts, sends via Resend with List-Unsubscribe + one-click header, and advances step. `email_sends` is the idempotent audit log (unique on `user+sequence+step`). Stale signups (>7d, no transcript, no current sequence) auto-enroll into `reengagement`. 12 templates total in `src/emails/`: welcome (5) signup→activate, post_activation (4) first transcript→paid, paywall (2) hit free-tier limit→upgrade with 20% promo on day 2, reengagement (1) founder personal voice. Sequence definitions in sequences.ts.
- `/api/email/unsubscribe` — one-click unsubscribe for lifecycle emails. Public, HMAC-signed token (sha256 of `unsub:{userId}` using `BETTER_AUTH_SECRET`), supports GET (footer link click) and POST (Gmail/Outlook one-click via List-Unsubscribe header). Sets `user_email_state.unsubscribedAt`; scheduler skips suppressed users. Transactional emails unaffected (separate path through `notifyTranscriptionComplete`). Footer link added to `wisprs-email-layout.tsx` when `unsubscribeUrl` prop is passed. Reactivation blast script (scripts/reactivation-blast.ts) with `--dry-run` for the existing cold users.
- Server-side GA4 (Measurement Protocol) — blocker-proof analytics. Existing `sendGA4` provider was a no-op without `GA4_API_SECRET` and used a synthetic `client_id` that broke GA4 sessions; client gtag.js was eaten by ad-blockers. Finished the path: first-party `wisprs_cid` cookie (set by app JS — blockers target tracker domains, not the site's own JS) threaded from page-view / conversion / GTM beacons; `sendGA4` now uses the threaded client_id, adds `timestamp_micros` + `session_id` + `engagement_time_msec` so MP hits form GA4 sessions, strips internal props. Client `gtag` configured with `send_page_view: false` (analytics-init.tsx) — server is the single page_view source. GTM dataLayer events (`purchase_confirmed`, `activation_confirmed`) now also POST to a new public `/api/events/gtm` → GA4. Verified blocker-proof: direct MP hit lands in Realtime with no client gtag. Setup: GA4 Admin → Data Streams → Measurement Protocol API secrets → create; set `GA4_MEASUREMENT_ID` + `GA4_API_SECRET` on prod.
- Mixpanel geo / device / plan enrichment + bot filter. All server-side events showed `(not set)` for City/Country/Browser because the user's IP was never forwarded to Mixpanel — events resolved to the VPS IP. New shared helper `userEventContext` extracts `ip` (Mixpanel resolves City/Country from it) + `$user_agent` (auto-parses Browser/OS/Device) + user plan; spread into the 3 client→server event routes (track, conversion, gtm). Identity stitching: `identifyUser()` merges anon `$device` profiles into the authenticated user with the SAME DB id the server uses, sets traits (`$email`, `$name`, `role`); wired into `TrackSignIn`. Bot filter (per Mixpanel docs, no project-level toggle exists): client checks `navigator.webdriver` + UA regex on init → `mixpanel.register({$ignore: true})`; server skips events with bot `$user_agent` in `sendMixpanel`. Webhook events (Clerk/Polar) intentionally omit `$ip` — their IP is the provider's datacenter, not the user.
- Product events — value-realization + revenue + activation. New events instrumented through the existing `trackEvent` pipeline: `transcript_exported` (export route) with format (txt/srt/vtt/docx/md/json), `transcript_shared` (share route) with permission level + has_password, `activated` (notifications/unified.ts) fires once on the user's FIRST successful transcription (DB-counted), `ai_feature_used` for summary/chat/translate/action-points via shared helper track-ai-feature.ts, `paywall_hit` (admission.ts) when free-tier hits the file-duration limit. `subscription_started` now carries `value` + `currency` (probed from Polar payload) for revenue analytics. `file_name` PII stripped from both Mixpanel and GA4 providers (kept for email path only). Site-wide `TrackUserActivity` moved from `/dashboard/layout.tsx` to root layout — captures `user_activity_daily.last_path` for authed users on every route, not just dashboard.
- Content calendar — file-driven runtime + full 90-day plan (~253 slots). The pg-boss cron, `/api/content/jobs/calendar-today`, and `scripts/content-calendar-daily.ts` previously resolved the schedule via `buildCalendarManifest()` (recomputed from code on a hardcoded `[5,5,7…]` ramp), so the committed `manifest.json` was only consulted for its `startMondayUtc` — edits to the file silently had no effect. New `resolveCalendarManifest()` reads the full `manifest` array from `content/calendar/manifest.json` and falls back to the code ramp; all 3 consumers (enqueue-due.ts, the calendar-today route, the daily script) now go through it. File is now the source of truth. Then expanded the plan: appended 180 curated `[keyword, family]` tuples to `BACKLOG_RAW` (sourced from `KEYWORD_MASTER_CATALOG.md` + `KEYWORD_CLUSTERS.md`, gap-filled from the Keywords-Everywhere CSV, deduped against ~159 live pSEO + blog + already-scheduled slugs); rewrote `scripts/generate-content-calendar-manifest.ts` to a preserve+extend generator (keeps June byte-for-byte, appends Jul 1–Aug 30 on a family-interleaved ribbon at 4/weekday + 1/weekend); raised `CONTENT_CALENDAR_TOTAL_CAP` 112 → 280. Result: manifest grew 73 → 257 specs through Aug 27, all slugs unique, zero overlap with live content. Family mix: blog 82, alternatives 44, use-cases 39, core 38, podcast 30, tools 24.
- Worker startup resilience — one failure can no longer kill the rest. `startWorkers()` registered ~10 workers in a single `await`-chain inside one `try/catch`, so a transient pg-boss/DB hiccup on an early worker silently aborted every worker after it. Observed in prod: transcription/upload workers up, but event (GA4/Mixpanel), free-tools, IndexNow, and the content-calendar cron worker never registered — leaving server-side analytics and the daily calendar dead until a manual `/api/workers/start` call. Wrapped each registration in a `safeRegister()` try/catch in start-workers.ts: log + continue. Analytics + the calendar survive boot transients.
- Marketing hub-and-spoke pages — `/tools`, `/alternatives`, `/podcast` index pages with `MarketingHubHero`, `PseoHubGrid`, and a `NavMenuDropdown` over the existing nav. New shared `src/lib/marketing-nav.ts` registry. Auditor: `scripts/audit-pseo-links.ts`.
- IndexNow `urls` audit column (src/lib/db/schema.ts). The `indexnow_runs` table previously logged only `url_count` per run, not which URLs were submitted. Added a nullable `jsonb` `urls` column + threaded through `logIndexNowRun` and the job handler so each run records the exact list (audit-friendly).
- Dashboard empty-state for first-run users. `src/components/dashboard-empty-state.tsx` — single primary CTA + a 3-benefit preview (transcript+speakers, summary+chapters, export-anywhere). Rendered conditionally in src/app/dashboard/page.tsx when `totalTranscriptions === 0`. This is the landing page every welcome lifecycle email's CTA points at — designed to convert the first transcription.
Changed
- Migrated admin tools from `/dashboard/*` to `/admin/*`. `/dashboard/content` (Library), `/dashboard/content-queue` (Queue + drill-down), `/dashboard/content/edit` (Editor), and `/dashboard/activity` (User activity) were all `requireAdmin()`-gated tools that ended up under the user dashboard by accident. 12 files moved via `git mv` (history preserved), all internal hrefs updated in a single pass. Per-page `requireAdmin()` kept (defense in depth — layout-level gate is the redirect, page-level is the throw). Redirect stubs left at the old `/dashboard/*` paths so any stale bookmark or in-app link still works (preserves search params). Admin sidebar Content + Users flipped from "soon" placeholders to live sub-nav. Three pre-existing type errors in the migrated content module fixed in the same pass: `content-edit-form.tsx:192` (`window.document.getElementById` — the `document: ContentDocument` prop was shadowing the global), `:265` (EmojiPicker `theme={EmojiPickerTheme.DARK}` instead of `theme="dark"`), `content/page.tsx:101` (in-try fallback referenced `all` before definition — mirrored the catch-block pattern).
- Middleware `ADMIN_API_TOKEN` bypass. The token check existed in `requireAdmin()` but `clerkMiddleware` ran first and rejected `/api/*` requests with `401 "Authentication required"` before any route handler ran — so the bypass was dead code for every middleware-gated admin endpoint. Added a passthrough in src/middleware.ts mirroring the `wisprs_sk_` API-key branch: a Bearer token equal to `env.ADMIN_API_TOKEN` is let through; `requireAdmin()` still performs the authoritative constant-time comparison. Plain `===` here because middleware runs in the Edge runtime (no `node:crypto` `timingSafeEqual`).
- Deploy ergonomics — local-build path + lockfile cleanup + skill docs. `DEPLOY_LOCAL_BUILD=1` (scripts/deploy-wisprs.sh) builds `.next` on the local machine and ships it via rsync, skipping the CPU-limited / hang-prone VPS build. Required new escape hatch: `DB_DISABLE_SSL=true` in src/lib/db/index.ts (prod-safe — unset on the server, so SSL stays on; lets the local build hit a non-SSL local postgres for `generateStaticParams`). Rsync now excludes `.next/cache` (cut rsync size 962MB → 344MB) and `.env.production.local` / `.env.*.local` (so a local-build env file can never override the server's runtime env). Lockfile reconciled: `next` 15.5.9 → 15.5.18 (the installed/VPS version), so deploys stop running the ~4-min VPS `npm install` on every push. Skill docs updated: `.claude/skills/wisprs-deploy/SKILL.md` has the full "Local-build deploy" section with all gotchas (the harness shell exports `ANTHROPIC_API_KEY=""`, etc.).
Fixed
- Internal `generated` blog tag visible on public blog pages. Agent-published posts default to `tags: ['generated']` in frontmatter (`src/lib/content/agents/publish.ts`) for CMS tracking, but the blog template rendered every tag beside the date (and on cards / the `/blog` filter sidebar). New `src/lib/content/public-tags.ts` with `getPublicBlogTags()` strips internal tags from public blog UI and SEO keywords; dashboard CMS still shows full tags.
Added
- `ADMIN_API_TOKEN` Bearer-token bypass for `requireAdmin()` (src/lib/auth/clerk.ts). Headless auth path for automation (daily triage cron, content scripts, scheduled tasks) — when env var is set AND request carries `Authorization: Bearer $ADMIN_API_TOKEN`, the request acts as a synthetic admin user (id: 0, email: `api-token@wisprs.local`) with no Clerk session needed. Constant-time comparison via `timingSafeEqual` prevents timing attacks. Min 32 chars enforced by env schema. Token check runs FIRST in `requireAdmin()`, falls through to Clerk session path if absent. Token lives in prod `.env.local` only — never committed. Generate with `openssl rand -hex 32`. Setup runbook: devops/ADMIN_AUTH_FOR_AUTOMATION.md (also covers the parallel long-lived Clerk session path for Chrome-MCP interactive work — "belt and suspenders").
Changed
- `.claude/skills/wisprs-pipeline-triage/SKILL.md` — explicit chains to other wisprs-\* skills. Pre-flight section now picks auth path (token preferred, Chrome MCP fallback) and instructs loading wisprs-pseo-content up front. Step 4 (editorial pass) explicitly chains into wisprs-pseo-content for the content quality bar — the mechanical EDITORIAL_PASS.md fixes are NOT a substitute for the full pSEO bar, just satisfy the validator. New Step 4.5 = CTA/link audit using wisprs-pseo-content rules (no /contact-sales, no bare /contact, no /start). Related-skills section expanded with chaining convention: load chained skill once per conversation, reference the source skill in editorial-decision outputs.
Changed
- Content calendar reset — new 90-day plan from June 1 2026 (→ Aug 30). Old calendar (Mar 16 → Jun 12) was 83% complete with 13 days left, ramp curve was running out. Regenerated `content/calendar/manifest.json` with date-ordered priority: close existing backlog first (73 slots Jun 1-30 — 18 core pillars, 12 podcast, 11 tools, 20 blog, 7 alternatives post-Phase-C dedup, 5 file-format use-cases), then forward expansion Jul 1 → Aug 30 (~180 slots requiring new keyword research, currently empty pending source updates). Cadence: 3 jobs/weekday + 1/weekend ("aggressive" tier). Slugs are interleaved across families so blogs ship throughout the window, not bunched at end. Excludes the 4 dedup-rejected `best-*` slugs from Phase C to prevent cannibalization (`best-ai-transcription-software`, `best-audio-to-text-converter`, `best-free-transcription-software`, `best-podcast-transcription-service`). Diagnostic note: 15 of 16 calendar-scheduled core pillars from the old plan are missing from disk — most likely cause is the deploy-drift clobbering pattern that was only fully fixed today; can be recovered via `/api/content/jobs/refresh-summaries` resurrection branch on next admin session.
Added
- `scripts/phase-c-cleanup.sh` — failed-queue triage script (template for future bulk reject/retry runs). Generated 2026-05-30 for the Phase C cleanup that rejected 19 zombie/dup failed jobs (4 OPENAI_QUOTA whose keywords already had live pSEO entities; 13 SHAPE_terminal regenerations of already-live blog posts; 1 DRAFT_BELOW_MIN dupe of `/blog/export-formats-explained`) and re-queued 1 genuinely net-new (`otter-ai-vs-descript` — a competitor-vs-competitor comparison angle not covered by the existing `wisprs-vs-otter-ai` and `wisprs-vs-descript` pages). All 20 job IDs hard-coded with per-job reject notes that document the dedup verdict. Uses `__session` cookie via `CLERK_SESSION` env var. Learning captured in memory: when the user is logged into a Chrome MCP tab, prefer `fetch(credentials:'include')` from the dashboard over standalone bash scripts that require cookie copy/paste — see `wisprs_admin_mutations_via_browser.md`.
- `scripts/sync-images.sh` — push `public/images/` to prod with no rebuild, no restart, no outage. Safe because `next.config.ts` does NOT set `output:'standalone'` — prod's `next start` reads `public/` directly from `$APP_DIR/public/` at runtime, so new static files appear on the next request. Mirrors `scripts/deploy-wisprs.sh`'s SSH config (`DEPLOY_SSH_TARGET`, `DEPLOY_APP_DIR`, `DEPLOY_SSH_KEY`, `DEPLOY_SSH_USER`, `DEPLOY_APP_USER`). Supports `bash scripts/sync-images.sh` (full sync), `bash scripts/sync-images.sh blog` (subdir scope), `bash scripts/sync-images.sh blog/{slug}-featured.webp` (single file), and `DRY=1` for preview. Idempotent (rsync checksum). Decouples the featured-image workflow (`wisprs-blog-featured-images` skill, run in Cursor) from the full `deploy-wisprs.sh` build+restart cycle — saves the 5-15s `systemctl restart` outage when only images change. Wired into the daily triage skill as Step 7 (`.claude/skills/wisprs-pipeline-triage/SKILL.md`): when a new blog publishes without a corresponding `/public/images/blog/{slug}-featured.webp`, the digest now flags it with the exact command to run.
- `.claude/skills/wisprs-pipeline-triage/` — daily morning content-pipeline automation. Encapsulates the manual flow used in the 2026-05-30 session into a re-runnable skill: pull queue counts → dedup-triage failed bucket (Phase C playbook) → auto-approve pending-review with pre-approval dedup check → run editorial pass on each newly-published page (H3 insertion for >120-word paragraphBlocks per the validator's definition, banned-verb scrub against the 30-phrase voice rubric, long-URL-sentence shortening, list-run break at >6 items) → verify live URLs → post digest. 4 skill files (SKILL.md, EDITORIAL_PASS.md, DEDUP_CHECK.md, FALLBACK_FILE_MODE.md). Backed by `scripts/triage/apply-editorial-pass.mjs` for file-based fallback when no Chrome session is available — idempotent (verified on `conference-transcription.md`: unchanged after re-run). Wired to a daily 8am scheduled task (`wisprs-daily-pipeline-triage`) that runs 2hrs after the 6am UTC `content-calendar` enqueue cron.
Fixed
- IndexNow daily delta schedule was overwritten by weekly full sweep. pg-boss keeps one `schedule` row per queue name; registering both crons on `indexnow-sitemap` left only the weekly full job in prod (`0 3 * * 0`). Split into `indexnow-sitemap-delta` (`INDEXNOW_DELTA_CRON`, default daily 3 AM) and `indexnow-sitemap-full` (`INDEXNOW_FULL_SWEEP_CRON`); worker startup unschedules the legacy queue. See `docs/events/ENV_SETUP.md`.
- Broken internal links in agent-generated content (`/start`, `/contact`, `/contact-sales`). The agent's draft prompts consistently routed users to non-existent routes — `/start` doesn't exist (the canonical free-tool flow is `/tools/free-audio-to-text`); `/contact` and `/contact-sales` don't exist (the canonical sales/enterprise route is `/enterprise`). Discovered while running `wisprs-content-audit` against the +47 new pages from the 2026-05-29 batch. Fixed instances:
- `/tools/free-mp3-to-text` — broken `Start transcribing` CTA hitting 404 → `/tools/free-audio-to-text`.
- `/blog/5-tips-for-better-transcription-accuracy` (pre-existing seed blog) — same `Start transcribing` 404 → `/tools/free-audio-to-text`.
- `/use-cases/medical-transcription-service` — `contact sales` 404 → `contact sales` (also fixed in this session's earlier Phase B edit chain).
- `/use-cases/legal-transcription-software` — same `contact sales` 404 → `/enterprise`.
- `/use-cases/academic-transcription-service` — `/contact-sales` 404 in plain-text CTA → markdown link to `/enterprise`.
Root-cause prevention: the draft agent prompt in `src/lib/content/agents/draft.ts` now includes a `<link_and_cta_rules>` block listing the real Wisprs routes (`/sign-up`, `/tools/free-audio-to-text`, `/enterprise`, `/sales`, `/demo`, `/pricing`, `/features`) and explicitly forbids the invented `/start` and `/contact-sales` paths — so future agent runs stop producing the same 404s.
- Paid long-file STT: ElevenLabs async webhook + bridge fallback. Files over 8 minutes leave transcriptions in `processing` with `elevenlabs_webhook_pending` when ElevenLabs accepts an async submit (`transcription_id` only), instead of marking completed with empty text. When ElevenLabs rejects `webhook=true` (e.g. `no_webhooks_configured`) or otherwise fails, paid jobs fall back to the async STT bridge using signed `/api/files/` URLs (`buildBridgeSourceUrl`). Docs: docs/guides/WEBHOOK_SETUP.md updated for ElevenLabs Transcription completed event and API-key/workspace alignment.
Added
- `scripts/apply-content-qa-fixes.mjs` — batch QA script for agent-generated content. Walks every entity in `content/pseo-programs/*.json` and post in `content/blog/*.md`, applying the mechanical fixes surfaced by a `wisprs-content-audit` pass across +47 new pages on 2026-05-29: (1) replaces the taboo verb `unlock` and its inflections with context-appropriate alternatives (`include`, `add`, `access` — the voice rubric bans it but the draft agent missed it, hitting 38 of 48 pages); (2) inserts a freshness signal `_Updated May 2026._` immediately after the H1 (the SEO/AEO agent's freshness step doesn't run on jobs that go terminal at the draft stage — affected 42 of 48 pages); (3) appends a `## Related on Wisprs` section with 3 family-specific internal links before the FAQ / closing CTA (`wisprs-pseo-content` requires 5+ internal links; audit measured average 2). Idempotent (skips entries already containing the freshness marker) and gated by a `SEED_*_SLUGS` allow-list so pre-existing seed pages are untouched. Family-specific link sets are defined in the script — `use-cases` link to `/pricing` + `/features` + meeting-transcription seed; `tools` link to `/pricing` + getting-started blog + `/features`; `alternatives` link to `/pricing` + `/features` + `wisprs-vs-otter-ai` seed; etc. Designed to be re-runnable after every prod content pull-back to apply the same QA bar to new entities.
- Content dashboard: filter cards, sortable columns, and date fields. `/dashboard/content` stat cards (TOTAL, BLOG, PSEO) filter the table via URL `kind` (`blog` / `pseo`; omitted = all) with a purple active border; TOTAL always shows the overall count (not the filtered table total). Table headers sort by any column (`sort` + `order` query params; default `publishedAt` desc). New Published and Generated columns (`publishedAt`, `createdAt`). Shared helpers in `src/lib/content/dashboard-content-query.ts` with tests in `dashboard-content-query.test.ts`; DB list sorting extended in `src/lib/content/db-queries.ts`.
- Content pull-back before deploy. `scripts/deploy-wisprs.sh` now rsyncs prod `content/pseo-programs/` (full) and new `content/blog/` files (`--ignore-existing`) into the local repo before push rsync, and aborts if `content/` differs from git — preventing approve-on-prod + deploy from archiving live pages via `content:sync`. Escape hatch: `DEPLOY_SKIP_CONTENT_PULLBACK=1`. Runbook: devops/CONTENT_PULL_BACK.md; cross-links in devops/WISPRS_DEPLOY.md, AGENTS.md, deploy skills.
- Brief-aware content summary derivation + admin backfill endpoint. New `src/lib/content/summary.ts` centralizes how meta description / OG description / schema description / page sub-tagline are derived for every blog and pSEO page: source priority is `brief.seoAndGeo.definitionOrQuotableOneliner` → `brief.searchIntent.whatPageMustAnswerInFirstParagraph` → first substantive prose paragraph after the H1 → fallback. The markdown extractor strips YAML frontmatter, skips H1s / horizontal rules / blockquotes / list items / code fences / CTA-led paragraphs (`Start…`, `Try…`, `Sign up…`, `Explore…`, etc.), strips inline markdown (links → anchor text, bold/italic/code markers removed), truncates at a sentence boundary when over 160 chars, and rejects directive-language brief fields ("the page should…", "page must…") so we don't ship instructions as descriptions. Tested in `src/lib/content/summary.test.ts` (14 cases including the H1-echo regression). New admin endpoint `POST /api/content/jobs/refresh-summaries` (`src/app/api/content/jobs/refresh-summaries/route.ts`) walks every `published` `content_job`, recomputes summary via `deriveSummary`, writes back to the source file (pSEO entity in `content/pseo-programs/*.json` or blog frontmatter in `content/blog/*.md`), then runs `syncContentToDb()` + `revalidateTag('content')` once at the end. Supports `?dryRun=true` (compute diffs, write nothing), `?slug=<slug>` (one-job backfill), `?limit=N`, and `?indexnow=true` (re-ping changed URLs after a real run). Idempotent: re-running with no agent changes produces no diffs.
- STT bridge health surface and host monitor. `src/app/api/health/route.ts` now exposes bridge status via `bridge.configured`, `bridge.healthy`, and `bridge.autostartConfigured`, backed by the shared helper in `src/lib/ai/bridge-health.ts`. Ops also gains host-level bridge liveness artifacts in `devops/configs/systemd/`: `wisprs-stt-bridge-healthcheck.sh`, `wisprs-stt-bridge-healthcheck.service`, and `wisprs-stt-bridge-healthcheck.timer`.
- STT bridge queue tuning: `stt-bridge/main.py` adds `GET /v1/metrics`, fast vs accurate lanes with `BRIDGE_ACCURATE_STARVE_SECONDS` aging, optional `BRIDGE_WORKER_THREADS` (1–4), `BRIDGE_STT_WHISPER_CPU_THREADS`, and `BRIDGE_STT_WHISPER_TRANSCRIBE_NUM_WORKERS`. Wisprs: `QUEUE_WORKER_TEAM_SIZE_TRANSCRIPTION_FREE` env (`src/lib/env.ts`, default 1, max 4) for free-tier pg-boss consumers. Docs: `devops/WISPRS_STT_BRIDGE.md`, `devops/RECON_RESULTS.md`, `stt-bridge/README.md`; `prewarm_models.py` honors `BRIDGE_STT_WHISPER_CPU_THREADS`.
- Env examples for bridge + free-queue tuning: `.env.example` adds commented `BRIDGE_STT_WHISPER_CPU_THREADS`, `BRIDGE_STT_WHISPER_TRANSCRIBE_NUM_WORKERS`, `BRIDGE_ACCURATE_STARVE_SECONDS`, `BRIDGE_WORKER_THREADS`, and `QUEUE_WORKER_TEAM_SIZE_TRANSCRIPTION_FREE`.
- DB script alias: `package.json` adds `pnpm db:deploy` → `drizzle-kit migrate` alongside existing `db:push` / `db:apply-migrations` flows.
- TOS-44 auth session + activity tracking. Wisprs now persists Clerk user IDs plus `last_sign_in_at` / `last_active_at` on `users`, records real sign-in/sign-out lifecycle in `auth_sessions`, and rolls daily engagement into `user_activity_daily` for DAU / WAU / MAU reporting. Clerk webhook handling now covers `session.created` and `session.ended`, protected dashboard navigation sends authenticated activity pings, and `getCurrentUser()` performs a throttled server-side activity touch as fallback when client JS does not report. Admins also get a new `/dashboard/activity` surface for logins today, DAU/WAU/MAU, recent sessions, recently active users, and dormant-user review. Files: `src/lib/db/schema.ts`, `src/lib/auth/sync.ts`, `src/lib/auth/clerk.ts`, `src/app/api/webhooks/clerk/route.ts`, `src/app/api/events/activity/route.ts`, `src/lib/activity/`, `src/app/dashboard/activity/page.tsx`, migration `drizzle/0001_abandoned_excalibur.sql`.
Fixed
- Polar webhook signature validation. `src/app/api/webhooks/polar/route.ts` now verifies Standard Webhooks headers (`Webhook-Id`, `Webhook-Timestamp`, `Webhook-Signature: v1,<base64_hmac>`) with signed payload `{msgId}.{unix_ts}.{body}` via `src/lib/billing/polar-webhook-signature.ts`, replacing the incorrect `x-polar-signature` + raw-body hex HMAC check that returned 401 for all deliveries.
- `refresh-blogs` silently template-flipped every existing blog into a reference-guide. `src/app/api/content/jobs/refresh-blogs/route.ts` used to fall through to a hard-coded `DEFAULT_TEMPLATE_KEY = 'reference-guide-v1'` whenever a blog file did not declare a `templateKey:` in frontmatter — which was 17 of 18 existing blogs. The agent dutifully regenerated each as a reference guide, replacing case studies ("How Podcasters Use Wisprs for Content Creation"), tips posts ("Mobile Transcription on the Go"), and listicles ("Cost-Effective Transcription Solutions") with "step-by-step" / "practical reference guide" rewrites that changed H1, framing, and content type — and then failed the article-shape validator anyway. The endpoint now resolves `templateKey` through an explicit priority chain (`?templateKey=` query override → blog frontmatter → `content_items.template_key` → skip with reason — never a silent default), validates against an `ALLOWED_BLOG_TEMPLATE_KEYS` allow-list so callers cannot accidentally route blogs through pSEO-only templates (`vs-comparison-v1`, `core-software-v1`, etc.), and returns a `skippedDetails` array per call so the operator sees exactly which blogs were skipped and why. Existing failed jobs in the content queue from this bug should be rejected, not approved — approving would replace live blog posts with the regenerated reference guides.
- TOS-45: upload-assembly queue verification jobs no longer create retry/failure noise. `src/lib/queue/upload-worker.ts` now ignores pg-boss health probe payloads like `{ _test: true }` instead of treating them as malformed real jobs. The sentinel logic lives in `src/lib/queue/upload-worker-state.ts` with a focused regression test in `src/lib/queue/upload-worker-state.test.ts`, so `verifyQueueCanAcceptJobs()` no longer pollutes `pgboss.job` for the upload queue.
- TOS-46: free-tier bridge outages now preserve a clearer failure reason. `src/lib/queue/transcription-worker.ts` now tags bridge submission failures with explicit `bridge_unavailable` metadata and a user-facing “free-tier speech-to-text service is temporarily unavailable” error via `src/lib/queue/transcription-failure.ts`. `src/lib/queue/stuck-transcription-cleanup.ts` preserves that bridge-down message instead of overwriting it with the generic dropped-job timeout when the cleanup worker later runs.
- Meta description / OG description / sub-tagline duplicated the H1 on agent-generated pSEO and blog pages. The previous extractor in `src/lib/content/agents/publish.ts` took the first non-blank line of `finalContent` and stripped `#` markers, so on agent-generated pages the entity `summary` ended up identical to the title (which becomes `content_items.description` → `content_seo.metaDescription` / `ogDescription` and the page sub-tagline via `descriptionTemplate: {{summary}}`). `src/lib/content/agents/publish.ts` now calls the new `deriveSummary(brief, finalContent)` (`src/lib/content/summary.ts`) for both the blog frontmatter `description` and the pSEO entity `summary`, so meta description now describes what the page is about instead of restating the title. Existing already-published pages can be repaired without re-running the agent pipeline via `POST /api/content/jobs/refresh-summaries` (admin-only). Verified end-to-end: `/use-cases/university-transcription-service` "before" was the verbatim 84-char H1; "after" is the 158-char prose lede.
- Users table alignment (prod / legacy DBs): `scripts/apply-migrations.sql` section 7 adds `clerk_user_id`, `last_sign_in_at`, and `last_active_at` on `users` plus matching indexes so Drizzle `select()` matches the schema (fixes `column "clerk_user_id" does not exist` when `processTranscription` or scripts load full user rows).
- Stuck transcription cleanup threshold: `src/lib/queue/stuck-transcription-cleanup.ts` now uses `updatedAt` (not `createdAt`) for `STUCK_TRANSCRIPTION_THRESHOLD_HOURS`, so transcriptions created long ago but just re-queued to the bridge are not immediately marked failed. Ops recovery: `scripts/reprocess-transcription-ids.ts` re-enqueues by id via `enqueueTranscription` and selects only `users.role` when needed (prod is now aligned; full `users` selects work after migration 7).
- Upload assembly / TOS-43 (stuck pending transcriptions). `src/app/api/upload/complete/route.ts` now parses `sessionId` as a strict positive integer (rejects `"undefined"`, floats, etc.) and enqueues assembly with the DB row’s `session.id`. `src/lib/queue/upload-worker.ts` validates `sessionId` / `userId` before work and `enqueueUploadAssembly` rejects invalid ids. Recovery: `scripts/recover-pending-transcriptions.ts` + `pnpm transcription:recover-pending` — re-queues `pending` signed-in rows whose `/api/files/` asset exists under `uploads/` (skips free-audio-to-text anonymous). Dedupes by transcription id when multiple file rows exist.
Changed
- Phase B editorial pass on 8 priority content pages from the 2026-05-29 batch. Per-page real editorial review against the `wisprs-content-audit` skill checklist (cluster alignment, persona match, plan precision, CTA-vs-intent, claim accuracy, internal link relevance). 27 surgical edits across: `/use-cases/sales-call-transcription` (CTA reframed `Upload one call` per the audit's prescribed sales-mode pattern, Sales Call Kit gated to Pro+ per FEATURES_TRACKING, `/alternatives/wisprs-vs-fireflies` cross-link); `/use-cases/medical-transcription-service` (Pro+ plan precision, dual-path trial+enterprise CTA, healthcare-specific audience metadata); `/use-cases/legal-transcription-software` (Pro+ precision, softened `legal-ready` overclaim, `/alternatives/wisprs-vs-rev` competitor cross-link); `/alternatives/wisprs-vs-fireflies` (sales-focused audience metadata, inline link to sales-call use-case page, meeting-frame CTA); `/tools/free-mp3-to-text` (broken `/start` 404 link fixed, duplicate freshness signal removed, signup path added); `/podcast/podcast-transcription-app` (actionable CTA links, blog cross-link); `/use-cases/academic-transcription-service` (Pro+ precision, markdown-link CTA, broken `/contact-sales` → `/enterprise`, universities-specific audience); `/blog/turn-podcast-into-blog-post` (markdown-link CTA, podcast pSEO cross-links). Deferred: `/alternatives/wisprs-vs-otter-ai` — seed entity uses programmatic-generated body, custom comparison content is dedicated content-writing scope. Followups for the brief agent: CTA patterns aren't matching the audit skill's prescribed intent mapping (`trial → 'Start transcribing'`, `Sales → 'Upload one call'`, `Enterprise → 'Contact Sales'`); most pages default to `Start transcribing` regardless of persona.
- Bridge deploy/runbook guidance now treats visibility and recovery as first-class ops checks. `devops/WISPRS_DEPLOY.md`, `devops/WISPRS_STT_BRIDGE.md`, `devops/configs/env.wisprs.production.template`, and `devops/configs/systemd/README.md` now document post-deploy bridge verification, `STT_BRIDGE_START_COMMAND` / `STT_BRIDGE_AUTOSTART_TIMEOUT_MS`, the host-level healthcheck timer, and the `bridge_unavailable` recovery playbook for Contabo.
- Deploy runs `content:sync` by default; STT bridge venv stays healthy on rsync deploys. `scripts/deploy-wisprs.sh` runs `content:sync` after migrations unless `DEPLOY_SKIP_CONTENT_SYNC=1`, and runs `pip install -r stt-bridge/requirements.txt` inside `stt-bridge/wisprs_venv` before restarting `wisprs-stt-bridge` (rsync excludes the venv) unless `DEPLOY_SKIP_STT_BRIDGE_VENV_SYNC=1`—fixes broken venvs missing `uvicorn` on Contabo. Docs: devops/WISPRS_DEPLOY.md, devops/WISPRS_STT_BRIDGE.md, `.cursor/skills/wisprs-deploy/`, AGENTS.md. Replaces opt-in `DEPLOY_RUN_CONTENT_SYNC=1`.
- Docs and skills (ops / SEO / publishing): updates across `devops/RECON_RESULTS.md`, `docs/SEO_STRATEGY.md`, `docs/guides/CONTENT_PUBLISHING_GUIDE.md`, `docs/guides/publishing-assets/blog-hero-image-pipeline.md`, `project_docs/pseo/SYSTEM_OVERVIEW.md`; small sync tweak in `scripts/sync-content.ts`. Cursor skills under `.cursor/skills/` (deploy, blog featured images). Claude Code skill copies under `.claude/skills/` (wisprs-\* skills) for parity with Cursor.
Fixed
- Free-tool job `downloadUrl` used internal origin behind proxy. `GET /api/tools/jobs/[id]` now builds absolute download links with `resolvePublicOrigin()`: prefers `NEXT_PUBLIC_APP_URL`, then `x-forwarded-proto` / `x-forwarded-host`, then `request.url`, with a production loopback fallback to `https://wisprs.co` so the MP4/MP3 button is not `http://localhost:2222/...` when the app sits behind nginx/Caddy.
- GSC 404s on root blog URLs (wrong canonical). Blog metadata and JSON-LD used `/{slug}` when `canonicalPath` was unset while pages only exist at `/blog/{slug}`, so crawlers hit 404s. `src/lib/content/seo.ts` now resolves public paths via `resolveDocumentPublicPath()` (`/blog/{slug}` for blog). `src/app/[slug]/page.tsx` 301 (`permanentRedirect`) to `/blog/{slug}` when a published post exists and there is no core pSEO page, recovering legacy and mis-linked URLs.
- Home proxy Squid: Template `scripts/home-proxy/squid.conf.wisprs` sets `dns_nameservers 1.1.1.1 8.8.8.8` so `brew services` Squid avoids `ERR_DNS_FAIL` when the service environment has no usable resolvers (symptom: `X-Squid-Error: ERR_DNS_FAIL` on `CONNECT`).
- Home proxy Squid: Template `scripts/home-proxy/squid.conf.wisprs` adds `connect_timeout 30 seconds` after `http_port` (Squid 7: `dns_v4_first` is obsolete — documented IPv6/DNS mitigations in `HOME_PROXY_SQUID_TAILSCALE.md` §2).
Added
- Free-tools yt-dlp observability. `HOME_PROXY_SQUID_TAILSCALE.md` §8 documents `journalctl`, Squid `access.log`, `free_tool_jobs`, Tailscale debug, and optional `FREE_TOOLS_YT_DLP_DEBUG` (`src/lib/env.ts`, `exec.ts`: `-v` + redacted argv + stderr on success). `.env.example` updated.
Changed
- YouTube and TikTok free-tool success cards: Download-ready state is a vertical stack (file download block, then “Ready for what’s next?” transcribe CTA) with clearer spacing and a separated Job ID row in `youtube-mp3-tool-client.tsx` and `tiktok-downloader-tool-client.tsx`.
- YouTube free-tool yt-dlp now includes `ytdlpProxyArgs()` so `FREE_TOOLS_HTTP_PROXY` applies to MP3/MP4 jobs and title extraction (`youtube-args.ts`), same pattern as TikTok (`tiktok-args.ts`).
- Home proxy doc: Smoke tests note that TikTok often times out through Squid; validate with example.com first; remote curl examples include example before TikTok (`HOME_PROXY_SQUID_TAILSCALE.md`).
- Home proxy doc: Clarify that §§1–3 run only on the home Mac; production env §4 runs on the Wisprs server (`HOME_PROXY_SQUID_TAILSCALE.md`).
Fixed
- Home proxy: use `tailscale serve --tls-terminated-tcp=3128` with `https://` clients. Raw `--tcp=3128` forwards plain TCP; `curl -x https://` then fails (wrong version number). `--tls-terminated-tcp` terminates TLS on the tailnet listener and forwards plain HTTP proxy bytes to Squid—matches `FREE_TOOLS_HTTP_PROXY=https://…ts.net:3128`. Updated `HOME_PROXY_SQUID_TAILSCALE.md`, `scripts/home-proxy/start-tailscale-serve.sh`, and `scripts/home-proxy/com.wisprs.tailscale-serve-squid.plist`. Mac must `tailscale serve reset` then re-run the script (or reload LaunchDaemon).
Added
- Home egress proxy (Squid + Tailscale) for `FREE_TOOLS_HTTP_PROXY`. Runbook `HOME_PROXY_SQUID_TAILSCALE.md`; Mac helpers in `scripts/home-proxy/` (Squid install script, Tailscale serve wrapper, optional LaunchDaemon plist, `squid.conf.wisprs`, production env snippet). Shared `ytdlpProxyArgs()` centralizes yt-dlp `--proxy`; TikTok provider args use it (`tiktok-args.ts`).
- wisprs-free-tool-ship: `seo-geo-stack.md`. Centralizes Wisprs SEO sources (`docs/SEO_STRATEGY.md`, `project_docs/pseo/KEYWORD_CLUSTERS.md`, `docs/seo/`) and when to apply seo-geo, ai-seo, and programmatic-seo during free-tool ships; wired from `SKILL.md`, `implementation.md`, `reference-keywords.md`, `free-tool-strategy/SKILL.md`, and `AGENTS.md`.
- Free TikTok Downloader (`/tools/tiktok-downloader`). Public tool page with async `tiktok-mp4` / `tiktok-mp3` jobs via yt-dlp, shared install health in `src/lib/free-tools/providers/yt-dlp-health.ts`, and optional datacenter IP unblock via `FREE_TOOLS_HTTP_PROXY` (`.env.example`). Providers `tiktok.ts` + `tiktok-args.ts`; URL validation in `validation.ts`. Client shows thumbnail, output size, and duration when metadata allows; transcribe handoff + upgrade CTAs (`tiktok-downloader-tool-client.tsx`). SEO/schema on `page.tsx`. Registered in `public-urls.ts`, Free TikTok Downloader in `navigation.tsx` / `mobile-nav.tsx`, and conversion allowlist in `events/conversion/route.ts`. Ops notes in `docs/growth/TOOLS.md`.
- Completed free-tool job API: optional media fields. `GET /api/tools/jobs/[id]` returns `outputSizeBytes`, `durationSeconds`, and `thumbnailUrl` when stored on the job (used by the TikTok tool UI).
- Cursor skills split for free tools. Wisprs implementation playbook at `.cursor/skills/wisprs-free-tool-ship/`; generic strategy stays in `.cursor/skills/free-tool-strategy/SKILL.md`. `AGENTS.md` links both.
Changed
- YouTube free-tool provider delegates yt-dlp presence checks to shared `yt-dlp-health.ts` (`youtube.ts`).
- Free-tool job typing widened for stored tool kinds / imports (`types.ts`, `db-queries.ts`).
- IndexNow scheduled runs now write `indexnow_runs` when there is nothing to submit. `src/lib/queue/indexnow-sitemap-job.ts` inserts an audit row with `url_count: 0` and the job `mode` (`delta` or `full`) when the delta window yields no URLs, so production DB checks reflect daily delta execution. Missing `NEXT_PUBLIC_APP_URL` is also logged as a failed run with a clear `error` message.
- Merchant-level shipping/return policy schema is now centralized and referenced by Product offers. `src/app/layout.tsx` now emits stable `@id` JSON-LD nodes for `OfferShippingDetails` (`/#shipping-policy`, US-first, digital/SaaS no physical shipping) and `MerchantReturnPolicy` (`/#return-policy`, `MerchantReturnNotPermitted`). `src/lib/content/seo.ts` now references those IDs from Product `offers.shippingDetails` and `offers.hasMerchantReturnPolicy`, aligning with Google’s recommended merchant-level policy pattern and reducing recurring Product-snippet warning noise without changing runtime behavior.
- FAQ schema emission is now validity-safe for rich results. `src/lib/content/seo.ts` now emits `FAQPage` only when it can construct a non-empty `mainEntity` from actual question/answer content; otherwise FAQ-typed pages fall back to `WebPage`. This prevents invalid FAQ rich-result items such as “Missing field `mainEntity`” on pages without structured FAQ pairs.
- Product schema now includes required `image` for rich result eligibility. `src/lib/content/seo.ts` now injects a resolved absolute image URL into `Product` JSON-LD (`document.heroImage` when available, otherwise the default Open Graph image), fixing Search Console invalid-item errors like “Missing field `image`” on pages such as `/ai-transcription-software`.
- Free Audio-to-Text failure UX and transcription queue guards were hardened to prevent misleading completion states and malformed-job crashes. `src/app/tools/free-audio-to-text/free-audio-to-text-client.tsx` now hides the progress bar in failed state (instead of showing `100%`) and adds clearer failed guidance that explains upload can complete while free transcription limits still block start. `src/lib/queue/transcription-worker.ts` now validates required job payload fields (`transcriptionId`, `filePath`, `fileSize`) both before enqueue and at worker intake, rejecting/dropping malformed jobs early instead of crashing with `ERR_INVALID_ARG_TYPE` on undefined file paths.
- Free Audio-to-Text landing SEO/GEO was upgraded with canonical cleanup and richer discovery signals. `src/app/tools/free-audio-to-text/page.tsx` now has stronger keyword-targeted metadata, explicit OG/Twitter images, improved `SoftwareApplication`/`FAQPage` fields, and cleaner breadcrumb structure. `src/app/tools/free-audio-to-text/free-audio-to-text-client.tsx` adds answer-first explanatory sections, expanded FAQ copy, and contextual internal links for crawl depth and conversion paths. Canonical drift was reduced by pointing footer links to `/tools/free-audio-to-text`, redirecting legacy `/tools/free-audio-to-text-converter`, and excluding that legacy path from public URL + llms listings in `src/lib/seo/public-urls.ts` and `src/app/llms.txt/route.ts`; sitemap priority/frequency for the canonical tool page was increased in `src/app/sitemap.ts`.
- Free Audio-to-Text now hard-stops at transcript success until the user explicitly imports into the dashboard. `src/lib/queue/transcription-worker.ts` and `src/lib/queue/bridge-completion-poller.ts` now skip locale detection and transcript-artifact prewarm for the free/self-hosted completion path itself (`userTier === 'free'` or `routingMetadata.queueClass === 'free-self-hosted'`), so anonymous free completions no longer run Clerk-adjacent or AI-enrichment follow-up work even if row metadata is imperfect. The public result UI in `src/app/tools/free-audio-to-text/free-audio-to-text-client.tsx` now frames the dashboard path as an explicit import/upgrade step while keeping the main CTA intact.
- Free Audio dashboard import now works for linked transcription jobs without output files. `src/app/actions/free-tools/import-to-transcription.ts` now handles `free-audio-to-text` jobs via `free_tool_jobs.transcriptionId` before the generic `outputPath` guard, preserving the explicit import CTA flow for users who choose to continue into Wisprs after receiving the anonymous transcript.
- Free Audio-to-Text handoff tokening and CTA flow were hardened for transcribe redirects. Free Audio submit and chunk-complete APIs now return a dedicated `handoffToken` (separate from polling `publicToken`) in `src/app/api/tools/free-audio-to-text/jobs/route.ts` and `src/app/api/tools/free-audio-to-text/upload/complete/route.ts`, and the client uses that token for handoff links/CTA tracking in `src/app/tools/free-audio-to-text/free-audio-to-text-client.tsx`. Handoff error rendering is now unified through `src/app/dashboard/tools/handoff/handoff-error-alert.tsx` from `page.tsx`, and conversion event validation now explicitly accepts `free-audio-to-text` in `src/app/api/events/conversion/route.ts`.
- Free Audio completed-state UX now replaces uploader with transcript-first view, with one-click reset. In `src/app/tools/free-audio-to-text/free-audio-to-text-client.tsx`, once transcription reaches completed with transcript text, the uploader card is hidden and the transcript card becomes the primary surface. The transcript textarea now defaults taller (`min-h-72`) while remaining expandable (`resize-y`), and a new Transcribe again action resets the entire flow back to the initial uploader state (file/link input, status, progress, transcript output, and file input DOM state).
- Free-tool transcriptions no longer prewarm AI artifacts before conversion. `src/lib/transcript-artifacts-prewarm.ts` now skips prewarm for free-tool-origin rows (`metadata.source` = `free-audio-to-text` / `free_tool`, or `userId` null), preventing background summary/action-point generation and related noisy logs until users enter the converted dashboard flow.
- Free Audio-to-Text now links orchestration UUID jobs to transcription rows for explicit funnel lifecycle tracking. Added `free_tool_jobs.transcription_id` linkage in schema (`src/lib/db/schema.ts`) plus migration script update (`scripts/apply-migrations.sql`). Free Audio creation paths now create a `free_tool_jobs` row first and link it to the created `transcriptions` row in both legacy submit (`/api/tools/free-audio-to-text/jobs`) and chunked complete (`/api/tools/free-audio-to-text/upload/complete`). Polling endpoint `/api/tools/free-audio-to-text/jobs/[id]` now resolves by free-tool UUID first (with temporary numeric fallback via linked transcription id), derives status from the linked transcription row, mirrors lifecycle status back to `free_tool_jobs`, and returns safe failed-state errors.
- Free-tool external IDs now use public UUIDs for audio-to-text jobs, and bridge auto-start is supported when unreachable. Free Audio-to-Text creation paths now generate/store `metadata.publicJobId` and return that as API `jobId` in `/api/tools/free-audio-to-text/jobs` and `/api/tools/free-audio-to-text/upload/complete`, while polling supports public ID first with temporary numeric fallback in `/api/tools/free-audio-to-text/jobs/[id]`. Added bridge supervisor auto-start helper `src/lib/ai/bridge-supervisor.ts`, connection-error detection in `bridge-stt-client.ts`, worker retry-on-autostart wiring in `transcription-worker.ts`, and env controls in `src/lib/env.ts` / `.env.example` (`STT_BRIDGE_START_COMMAND`, `STT_BRIDGE_AUTOSTART_TIMEOUT_MS`).
- Free Audio-to-Text now uses chunked public uploads for file reliability, with legacy submit fallback retained. Added public chunked endpoints `/api/tools/free-audio-to-text/upload/init`, `/upload/chunk`, and `/upload/complete`, backed by tokenized upload session helpers in `src/lib/free-tools/public-upload-session.ts` and client uploader logic in `src/lib/free-tools/public-chunked-uploader.ts`. Updated `free-audio-to-text-client.tsx` to upload files through init→chunk→complete (with retries and progress-aware pending state) while preserving direct-link submit via legacy `/api/tools/free-audio-to-text/jobs`. Middleware now allows the new public upload routes in `src/middleware.ts`.
- Free Audio-to-Text tool shipped with public intake API, stronger uploader UX, and navigation integration. Added the new route shell at `/tools/free-audio-to-text` and client flow in `free-audio-to-text-client.tsx`: drag/drop uploader with selected-file controls, file/link mutual exclusivity, status badge animation parity with YouTube free tools, gradient-tinted card treatment, and clearer FAQ separation. Added public submit/poll endpoints under `/api/tools/free-audio-to-text/jobs` and `/api/tools/free-audio-to-text/jobs/[id]` with server-side gating (allowed extensions, 500MB cap, 10-minute limit) plus anonymous token polling. Registered the tool in discoverability/public routing via `src/lib/seo/public-urls.ts`, middleware public route allowlist in `src/middleware.ts`, and both desktop/mobile Free Tools navigation entries in `src/components/navigation.tsx` and `src/components/mobile-nav.tsx`.
- YouTube free-tool pages now have distinct SEO/GEO intent, schema, and support-cluster coverage. Updated route-level metadata/canonicals for `/tools/youtube-video-downloader` and `/tools/youtube-audio-downloader`, expanded answer-first/FAQ copy in the shared shell `youtube-mp3-tool-client.tsx`, and added JSON-LD (`SoftwareApplication`, `BreadcrumbList`, `FAQPage`) per page. Added supporting pages `/tools/youtube-video-to-text` and `/tools/how-to-transcribe-youtube-video-to-text`, plus crawl/discoverability registration in `src/lib/seo/public-urls.ts`. Growth keyword mapping and post-rollout backlog documented in `docs/growth/YOUTUBE_TOOL_SEO_KEYWORD_MAP.md` and `docs/growth/TOOLS.md`.
- Free tools → transcribe handoff flow (auth-safe, reusable, and conversion-tracked). Added shared handoff URL helpers in `src/lib/free-tools/transcribe-handoff.ts`, a reusable CTA in `src/components/free-tools/free-tool-transcribe-cta.tsx`, and a new handoff route `src/app/dashboard/tools/handoff/page.tsx`. Handoff import now runs through `src/app/actions/free-tools/import-to-transcription.ts`, preserving the normal “ready to transcribe” confirmation experience and idempotency by `freeToolJobId`.
- Public downloader success UX and transcribe journey refined. The completed state in `src/app/tools/youtube-mp3/youtube-mp3-tool-client.tsx` now hides the form, uses nested left/right cards, keeps Download/Download again in the left panel, and routes both the big transcribe CTA and inline Log in link through the same handoff flow in a new tab.
- Display filename resolution improved for completed jobs/imports. Job status API `src/app/api/tools/jobs/[id]/route.ts` now returns `displayFileName` using `resolveImportedFreeToolFileName`. Worker/provider metadata paths were hardened: embedded tag title extraction in `src/lib/upload/metadata-extractor.ts`, worker fallback in `src/lib/queue/free-tools-worker.ts`, and YouTube provider metadata embedding via `yt-dlp --add-metadata` in `src/lib/free-tools/providers/youtube.ts`.
- Conversion analytics pipeline extended for free-tool funnel events. Added allowlisted client event endpoint `src/app/api/events/conversion/route.ts`, new event names in `src/lib/events/types.ts`, and event-worker compatibility handling in `src/lib/queue/event-worker.ts` so new funnel events are processed instead of dropped.
- Local storage provider now persists uploaded bytes in development. `src/lib/storage.ts` local provider now writes/deletes files under `uploads/`, fixing `/api/files/...` 404s for imported free-tool transcriptions.
- Free-tools submit defaults raised to 50/hour. `src/lib/env.ts` defaults for `FREE_TOOLS_SUBMITS_PER_IP_PER_HOUR` and `FREE_TOOLS_SUBMITS_PER_DEVICE_PER_HOUR` increased from `10` to `50`.
- Free tools are now publicly accessible without login and protected by backend abuse controls. `src/middleware.ts` now allows `/api/tools/jobs(.*)` without Clerk session checks. `src/app/api/tools/jobs/route.ts` enforces per-hour limits by hashed IP and anonymous device cookie (`wisprs_ft`), with Redis-backed counters and in-memory fallback via `src/lib/free-tools/rate-limit.ts`. New env controls: `FREE_TOOLS_SUBMITS_PER_IP_PER_HOUR` and `FREE_TOOLS_SUBMITS_PER_DEVICE_PER_HOUR`. Client requests include credentials for cookie-based limit continuity in `src/app/tools/youtube-mp3/youtube-mp3-tool-client.tsx`.
- YouTube free tools UX/navigation and routes were expanded for pSEO discoverability. Added dedicated routes for `/tools/youtube-video-downloader` and `/tools/youtube-audio-downloader`, with legacy `/tools/youtube-mp3` redirecting to video downloader in `src/app/tools/youtube-mp3/page.tsx`. Desktop and mobile navigation now expose separate video/audio tool links in `src/components/navigation.tsx` and `src/components/mobile-nav.tsx`. The downloader page now includes dynamic server status badges, gradient CTA styling, and queue/progress UX refinements in `youtube-mp3-tool-client.tsx`.
- Homepage directory badges: Fazier; badges removed from footer. `src/components/directory-badges.tsx` adds the Fazier featured launch badge (`fazier.com/launches/wisprs.co`, official SVG). `src/components/footer.tsx` no longer renders `DirectoryBadgesRow`; directory badges appear only in the trust strip below the homepage hero (`src/app/page.tsx`).
- Blog hero overlay WebP sibling — `scripts/overlay-blog-hero.ts` writes `{slug}-featured.webp` next to `.png` (Sharp WebP q92/effort 6; `--no-webp` to skip). Docs/skills cross-link wisprs-marketing-images. Example post `heroImage` → `.webp`.
- Blog featured image layout — Post hero and `BlogPostCard` use 1200×630 aspect (`src/lib/content/blog-featured-image.ts`) instead of fixed `h-64`/`h-48` + `object-cover`, so OG-style artwork is not cropped at the bottom.
- Deploy: `content:sync` opt-in — `scripts/deploy-wisprs.sh` skips `npm run content:sync` by default so deploys no longer upsert `content/blog` + pSEO JSON into prod. Set `DEPLOY_RUN_CONTENT_SYNC=1` to run it (still uses `keep-db-if-any` for blog bodies). Manual sync on server documented in `devops/WISPRS_DEPLOY.md`. Skill `wisprs-deploy` updated.
- Production `content:sync` blog bodies — `src/lib/content/sync.ts`: when `NODE_ENV=production`, default `keep-db-if-any` — if the blog row already has a body, do not replace it from `content/blog/*.md`. When sync runs: override with `from-file`. Documented in `.env.example`, `devops/WISPRS_DEPLOY.md`, `docs/guides/CONTENT_PUBLISHING_GUIDE.md`.
Added
- Blog hero image pipeline (docs). `docs/guides/publishing-assets/blog-hero-image-pipeline.md` — runbook for featured/OG art: plate → Satori + Resvg + Sharp, `heroImage` wiring, troubleshooting; reference asset `public/images/blog/getting-started-with-audio-transcription-featured.png`. Linked from `docs/guides/publishing-assets/README.md`, `docs/README.md`, `docs/guides/CONTENT_PUBLISHING_GUIDE.md`.
- Homepage directory trust strip. `src/app/page.tsx` — “Listed & featured on” + `DirectoryBadgesRow` between hero and How it works; label `HOME_DIRECTORY_BADGES_LABEL`.
- Phase 2 + ramp content calendar. `src/lib/content/calendar/` defines Phase 2 job specs, KEYWORD_CLUSTERS backlog, and a 5→7→10→12 weekly manifest; `content/calendar/manifest.json` is generated via `pnpm content:calendar:manifest`. pg-boss queue `content-calendar` (`src/lib/queue/content-calendar-job.ts`) enqueues daily jobs; `pnpm content:calendar:enqueue` and admin routes `POST /api/content/jobs/calendar-today` / `seed-phase2`. Env and ops: `devops/CONTENT_CALENDAR.md`, `content/calendar/README.md`, `project_docs/pseo/90DAY_PSEO_STATUS.md`.
Changed
- Content queue: Approve failed jobs with salvageable output. Draft-stage article-shape exhaustion used to set `failed` with only `draft`, so Approve never appeared. `src/lib/content/agents/orchestrator.ts` now sets `pending-review` with `finalContent` + Fix list (same idea as humanizer QA warnings). `src/lib/content/job-salvage.ts` + `src/app/api/content/jobs/[id]/approve/route.ts` allow Approve for existing `failed` rows when `draft` / `seo_optimized` / `humanized` / `final_content` is present (best stage wins). `content-queue-client.tsx` and review actions show Approve/Reject accordingly; `reject` accepts `failed`. Review page Open page uses correct path for `alternatives` / `podcast` / `use-cases` / `tools`.
- Content edit: pipeline body preview for pSEO + full slug match. `src/app/actions/content-cms.ts` matches `content_jobs` when the editor URL uses a full slug (`alternatives/wisprs-vs-descript`) while jobs store the entity slug (`wisprs-vs-descript`); includes `final_content` in the lookup. `src/app/dashboard/content/edit/page.tsx` applies `bodyOverride` for pSEO as well as blog so humanized output shows before Approve. Publishing still runs only via `POST /api/content/jobs/[id]/approve` (writes program JSON + `syncContentToDb`).
- Homepage directory trust strip background. `src/app/page.tsx` — same violet/pink radial overlay as “How it works” plus a short top fade from hero black so the strip reads as one continuous page instead of a flat band.
- Marketing PNGs losslessly optimized — `marketing/images/` PNGs recompressed with `oxipng -o max --strip all -a` (same filenames; ~2.9 MiB total savings).
Added
- MarketingDB footer badge. `src/components/footer.tsx` — “Listed on MarketingDB” badge (official embed) in the footer bar for directory verification and backlinks.
- FoundrList footer badge. `src/components/footer.tsx` — official “Live on FoundrList” embed (`/product/wisprs`, `rel="nofollow noopener noreferrer"`) next to MarketingDB for badge verification.
- ShowMeBestAI footer badge. `src/components/footer.tsx` — “Featured on ShowMeBestAI” embed (`feature-badge-dark.webp`) with directory badges.
- ToolFame footer badge. `src/components/footer.tsx` — “Featured on toolfame.com” embed (`/item/wisprs`, `badge-dark.svg`, 54px height).
- Turbo0 footer badge. `src/components/footer.tsx` — “Listed on Turbo0” embed (`turbo0.com/item/wisprs`, `badge-listed-dark.svg`, 54px height) for backlink verification.
- Public demo page for promo video. `src/app/demo/page.tsx` — `/demo` serves the same hero promo (WebM + MP4 + poster) with native controls, OG/Twitter metadata, and direct file links for launches (Product Hunt, social). Public in `src/middleware.ts`; included in sitemap via `src/lib/seo/public-urls.ts`. `docs/PROMO_VIDEO.md` documents primary vs direct URLs. Middleware matcher skips `.mp4` / `.webm` so static video files do not run Clerk.
- STT bridge model prewarm. `stt-bridge/prewarm_models.py` downloads/loads faster-whisper checkpoints for `BRIDGE_STT_WHISPER_MODEL_FAST` and `BRIDGE_STT_WHISPER_MODEL_ACCURATE` using production env (run on server after deploy so “Best quality” does not block on first HF fetch).
- Free-tier STT routing tests. `src/lib/stt-quality.test.ts` — `npx tsx --test src/lib/stt-quality.test.ts`.
- Contabo vmi3157334 systemd preset. devops/configs/systemd/wisprs-stt-bridge.service.contabo-vmi3157334 (12 vCPU / 47 GiB, MemoryMax=24G, CPUQuota=1000%, Whisper env). devops/RECON_RESULTS.md contabo-prod table filled from live `ssh contabo-prod`.
- STT bridge: env-tunable faster-whisper. `BRIDGE_STT_WHISPER_DEVICE` (default `cpu`), `BRIDGE_STT_WHISPER_COMPUTE_TYPE` (default `int8`), `BRIDGE_STT_WHISPER_MODEL_FAST` / `BRIDGE_STT_WHISPER_MODEL_ACCURATE` (defaults `small` / `large-v3`). Documented for CPU-only Contabo sizing in devops/CONTABO_MIGRATION.md §6, devops/WISPRS_STT_BRIDGE.md, stt-bridge/README.md.
- Contabo migration runbook and large-host STT unit. New devops/CONTABO_MIGRATION.md (SSH e.g. `contabo-prod`, recon, provision Postgres/nginx/systemd, `pg_dump`/restore, `.env.production`, deploy overrides, DNS, STT tuning). devops/RECON_RESULTS.md gains Server hardware (contabo-prod) table (fill after recon). devops/configs/systemd/wisprs-stt-bridge.service.large-host.example for higher MemoryMax/CPUQuota; devops/configs/systemd/README.md indexes units. devops/WISPRS_DEPLOY.md, devops/app/WISPRS_DEPLOYMENT.md, devops/WISPRS_STT_BRIDGE.md, AGENTS.md, scripts/deploy-wisprs.sh, and wisprs-deploy skill reference Contabo deploy.
- GSC “Discovered – not indexed” runbook and internal links. New `docs/seo/GSC_COVERAGE_RUNBOOK.md` documents live verification, triage vs robots/4xx, and manual GSC steps. Footer adds links to use cases, comparisons (`/alternatives/best-otter-ai-alternatives`), free audio-to-text tool, and podcast transcription; desktop and mobile nav add Compare to the same alternatives hub. Root `metadata.robots` is explicitly `index, follow` for clarity (dashboard remains disallowed in `robots.txt`).
- Manual IndexNow sitemap submit script. `scripts/indexnow-submit-sitemap.ts` (and `pnpm indexnow:submit-sitemap` / `pnpm indexnow:submit-sitemap:dry`) submits the same URL list as `/sitemap.xml` and the nightly pg-boss job; supports `--dry-run`; optional `indexnow_runs` row when `DATABASE_URL` is set. Documented in `docs/events/ENV_SETUP.md` and `devops/WISPRS_DEPLOY.md`.
- IndexNow verification file (openssl key). Added `public/{INDEXNOW_KEY}.txt` with a key generated via `openssl rand -hex 16`, matching Trevor Lasn’s IndexNow setup. Production must set `INDEXNOW_KEY` in `.env.production` to the same value. Docs: `docs/events/ENV_SETUP.md` and `.env.example` updated (Bing IndexNow vs Webmaster API key).
- STT accuracy reference and benchmark harness. New `docs/reference/STT_ACCURACY_AND_BENCHMARKS.md` (model inventory, vendor sourcing, Wisprs-measured WER template). `scripts/stt-benchmark/` with word-level WER (`wer.ts`), OpenAI + ElevenLabs runners, `--verify-wer` sanity check; `pnpm stt-benchmark:verify`. Outputs go to `benchmark-results/` (gitignored). Docs index, pitch deck appendix, and STT bridge doc link to the reference.
- Content queue: status badge and live polling. Reusable `ContentJobStatusBadge` (backed by `getStatusDisplay()` in `src/lib/content/status-display.ts`) used on the queue list and review page for consistent status styling. Review page mounts `ContentQueueReviewPoller` to poll `GET /api/content/jobs/[id]/status` every 12s while the job is in an active pipeline stage and calls `router.refresh()` when status changes so the badge and content stay in sync without leaving the page.
- Blog edit: editable slug. Content edit form (blog only) now has a Slug field so the article URL can be changed. Saving with a new slug writes the file under the new name, deletes the old markdown file, and revalidates both `/blog/[old-slug]` and `/blog/[new-slug]`. Server action `saveBlogContentEdit` accepts optional `previousSlug` and removes the previous file when the slug changes.
- Next.js image config and FileCard thumbnails. `next.config.ts` includes `images.remotePatterns` for localhost (dev) and a comment for adding production storage hostnames. `FileCard` uses `next/image` for thumbnail URLs; blob and data URLs use a plain `<img>` with an eslint-disable (next/image does not support them).
- Dependency scripts and security overrides. `package.json` scripts `deps:audit` and `deps:outdated` for quick checks. pnpm overrides force patched versions of transitive deps (hono, @hono/node-server, flatted, esbuild, lodash, @tootallnate/once) so `pnpm audit` reports no known vulnerabilities.
- Content edit: pipeline output and Fix list for human review. When a content job exists for a blog slug (e.g. after a pipeline run or retry), the content edit page loads the latest job’s draft or humanized body as the initial editor content and shows the Fix list (editorSummary action points) for reference. New `getContentJobEditorContext(slug)` in `content-cms.ts` fetches the latest job by `targetSlug`/`slug` with `editorSummary` or body; the edit page merges `bodyOverride` into the document and passes `editorSummary` to the form.
- GPT-5.3 Chat in LLM seed for draft/humanizer. `gpt-5.3-chat-latest` (GPT-5.3 Instant) added to `scripts/seed-llm-models.ts` with `prioritySummary: 1` so draft and humanizer use it when OpenAI is preferred. Run `npm run db:seed-models` to apply.
- Blog featured section and backend tagging. Blog page shows a "Featured" section at the top (up to 6 posts) for any post whose tags include `featured`. Content edit form (blog only) has a "Feature on blog" checkbox that adds or removes the `featured` tag. Three articles (Getting Started with Audio Transcription, New Feature: AI-Powered Summaries, Transcription accuracy tips) were labeled as featured in frontmatter and synced to the DB via `pnpm content:sync`.
- Content edit: full emoji picker and Option A markdown improvements. (1) Emoji: Replaced the fixed row of 10 emojis with a single “Emoji” button that opens a full dropdown picker (`emoji-picker-react`), rendered in a portal so it isn’t clipped by the card; dark theme, click-outside to close, viewport clamping so the picker stays on screen. (2) Image upload: New `POST /api/content/upload-image` (admin-only) accepts an image file, stores under `public/images/content/<yyyy-mm>/`, returns `{ url }`. Editor has “Upload image” (file picker), and paste/drop of images uploads and inserts `!alt` into the body. (3) Columns: Markdown renderer allows safe block HTML (sections that start with `<div` and end with `</div>`); sanitized with `isomorphic-dompurify` (allow-list tags/attrs). “Insert columns” button in the editor inserts a two-column grid block (Tailwind `grid grid-cols-1 md:grid-cols-2 gap-4`). No storage or pipeline changes; body remains Markdown.
- Editor summary for failed content jobs. When draft or humanizer exhaust retries (article-shape or word-count), the pipeline now stores the last content (draft or humanized) and an `editorSummary` (jsonb) with 5–6 short, actionable items for the human editor. New `content_jobs.editor_summary` column; `src/lib/content/agents/editor-summary.ts` maps shape failures to plain-English fixes (e.g. "Keep list runs short…", "Add a short paragraph before each list section…"). Orchestrator persists draft on draft terminal failure and sets `editorSummary` on both draft failure and humanizer shape/word-count exhaustion. Run `npm run db:push` to add the column. UI can show `editorSummary.actionPoints` and edit content from `draft` or `humanized` per `contentForEditor`.
- Content list pagination and dashboard counts. Dashboard content list (`/dashboard/content`) shows 10 items per page with Previous/Next and “Showing X–Y of Z.” New `getDashboardContentCounts()` returns total/blog/pseo counts for stat cards. When the DB returns 0 items, the list falls back to file-based content from `content/blog` so the page is usable before `npm run content:sync`.
Changed
- Free-tier transcription mode → bridge model. `resolveFreeTierDesiredAccuracyMode`: Speed → `fast` (small), Best quality → `accurate` (large-v3), Auto → `accurate` only when duration and file size are within the same caps as worker force-fast (else `fast`). `processTranscription` always sets `accuracyMode` for free tier; `retranscribeTranscription` copies `sttQuality` from the source row. `submitBridgeJob` sets `routing.preferredMode` to match `accuracyMode`.
- Deploy: optional STT bridge unit sync. `DEPLOY_SYNC_STT_BRIDGE_UNIT=1` in `scripts/deploy-wisprs.sh` copies `devops/configs/systemd/wisprs-stt-bridge.service.contabo-vmi3157334` to `/etc/systemd/system/wisprs-stt-bridge.service` and runs `daemon-reload`. Documented in `devops/WISPRS_DEPLOY.md` and `.cursor/skills/wisprs-deploy/`.
- Contabo STT bridge preset: `int8` not `int8_float16`. devops/configs/systemd/wisprs-stt-bridge.service.contabo-vmi3157334 sets `BRIDGE_STT_WHISPER_COMPUTE_TYPE=int8`; on this CPU-only host (faster-whisper 1.2.1 / ctranslate2 4.7.x), `int8_float16` and `float16` fail at runtime. devops/RECON_RESULTS.md updated. On the server: copy the unit (or edit `Environment=` to `int8`), then `daemon-reload` + `restart wisprs-stt-bridge`.
- Accuracy copy (fact-check alignment). `COPY_POLICY_ACCURACY` and site surfaces (marketing copy, FAQ, product FAQs, features data, home schema, footer, how-it-works, creators, llms.txt, default mode) use qualified wording (excellent accuracy on clear audio; varies by language/conditions) instead of fixed “99%” claims. `project_docs/pseo/FEATURES_TRACKING.md` updated for the new policy.
- Fix list shows exact lines/paragraphs that failed QA. The content edit Fix list (from pipeline) now shows concrete excerpts for failures: long sentences, long/short paragraphs display the offending text (truncated to 120 chars) under each instruction. Article-shape validator returns `paragraphBlocks` and `sentences`; `buildShapeFindings()` maps failures to editor-facing findings with optional excerpts. Editor summary accepts `findings` and stores `actionPoints` as `{ message, excerpt? }[]`; the edit form renders each point with an optional quoted excerpt below. Legacy jobs with string-only action points still render correctly.
- Content edit layout: editor full row, then Fix list and meta. The content edit page now uses a two-row layout: the body (markdown) editor uses the full first row; the Fix list (from pipeline) and metadata cards (Title, Description, Primary keyword, etc.) are on the second row, with Fix list and meta side-by-side on large screens when editor summary is present.
- Content edit: sidebar, layout, and action bar. When opening the content edit page, the dashboard sidebar is collapsed by default to give the editor more horizontal space (new `setCollapsed` in sidebar context and `SidebarCollapseOnEdit` in the edit layout). Edit page container widened to `max-w-[1600px]`. Action bar: “← Content” stays left; “Open page,” “Revalidate + submit,” and “AI context” are grouped on the right; “Open page” uses the same bordered button style as the others.
- Content list: Revalidate + submit and AI context moved to edit page. The content list Actions column now only has Edit and Open page. “Revalidate + submit” and “AI context” live on the content edit page (`/dashboard/content/edit`) so they sit with the piece being edited.
- Homepage pricing: Most Popular badge and Enterprise card. “Most Popular” badge is rendered outside the Studio card so it is not clipped by `overflow-hidden`, uses a background-matching border, and the popular card has top padding so the badge sits clear. Enterprise card layout and copy match the pricing page (title, Custom + “Starting at $300/month,” tagline, Contact Sales, features); Enterprise spans the full bottom row (`md:col-span-3` on the grid item).
Fixed
- `sttQuality` missing from `transcriptions.metadata` after completion. The worker’s first `processing` update and bridge/sync completion paths replaced the entire `metadata` JSON instead of merging, dropping `sttQuality`, `queuedAt`, and other keys set at upload/start. `transcription-worker.ts` now merges on processing start, synchronous completion, and generic failure; `bridge-completion-poller.ts` merges existing metadata (passed from the poller) when applying bridge results. Chunked `upload-worker.ts` persists `sttQuality` whenever the session includes it (including `auto`). Post-deploy check: `SELECT id, metadata->>'sttQuality', metadata->>'bridgeMode' FROM transcriptions WHERE status = 'completed' ORDER BY id DESC LIMIT 5;` — expect `sttQuality` alongside bridge fields for new jobs.
- Transcriptions dashboard pagination. `src/app/dashboard/transcriptions/page.tsx` now defaults to `10` rows per page and keeps the pagination controls visible (with “Showing X–Y of Z”) while clamping `currentPage` when filters/sorts change.
- `sttQuality` missing on uploads created via `POST /api/upload`. `src/app/api/upload/route.ts` now always persists `{ sttQuality }` in `transcriptions.metadata` (including `auto`), instead of only persisting when the preference was not `auto`, so `metadata->>'sttQuality'` is reliably present for analytics/debug and can be preserved through transcription completion.
- Blog featured images: hero image and center icon. Featured cards and the single post page now use `post.heroImage` when set (including `/images/blog/...`) instead of always showing the fallback logo. The gradient overlay with the center “failed loading” SVG icon was removed from both `BlogPostCard` and the blog `[slug]` page so the hero image displays without a placeholder icon.
- Lint: 8 errors and 7 warnings. Replaced internal `<a href="...">` with `<Link>` on the transcriptions page (no-html-link-for-pages). Escaped apostrophes with `'` in forgot-password, sales, sign-in, and checkout-success-celebration (no-unescaped-entities). Resolved exhaustive-deps in text-to-speech, wisprs success, multi-file-upload, sales-call-kit, transcription-status-tracker, and use-transcription-status; switched file-card thumbnail to next/image with blob/data fallback; `pnpm run lint` passes with no warnings or errors.
- Build: jsdom default-stylesheet for server bundle. Added `browser/default-stylesheet.css` (copy from jsdom) so the Next server bundle can resolve the path when isomorphic-dompurify (and thus jsdom) runs during page-data collection, fixing “Failed to collect page data for /podcast/[slug]”.
- IndexNow 422 (key verification and URL scope). Nightly IndexNow submissions were failing with HTTP 422 because (1) the key URL (`/api/indexnow-key`) was protected by auth, so Bing/Yandex could not read it, and (2) with the key under `/api/`, IndexNow Option 2 only accepts URLs under that path; we submit `/`, `/blog/...`, etc. Fix: key endpoint is now public; middleware rewrites `/{INDEXNOW_KEY}.txt` to the key handler (Option 1 root key) so all site URLs are valid; provider uses `keyLocation = https://host/{key}.txt` by default so no env change is required. Failed runs now store the API response body in `indexnow_runs.error` for easier debugging.
- Health endpoint 401. `GET /api/health` was behind auth and returned 401 for unauthenticated requests. It is now in the middleware public route list so load balancers and monitoring can hit it without auth.
- Powered-by strip: remove border around Eleven Labs logo. The Eleven Labs logo in `PoweredByStt` no longer has `ring-1 ring-white/15` so it matches the borderless OpenAI logo.
Added
- Admin-only Content CMS and content queue. Content list (`/dashboard/content`), content edit (`/dashboard/content/edit`), and content queue (`/dashboard/content-queue`) are restricted to users with `users.role === 'admin'`. Single gate: `requireAdmin()` in `src/lib/auth/clerk.ts` (auth + role check). Optional local bootstrap via `ADMIN_EMAILS` (comma-separated emails) in `.env.local`; `getCurrentUser()` upgrades matching users to admin in the DB on next request. Guarded: content list/edit/queue pages (redirect to `/dashboard` when not admin), Server Actions in `content-cms.ts` and `content-admin.ts`, and content job APIs (`GET/POST /api/content/jobs`, approve/reject/retry, refresh-blogs, `GET /api/content/automation/[slug]`). API routes return 403 JSON when not admin. Admin docs: `docs/admin/` with README (overview, TOC), DESIGN.md (scope, request flow, failure behavior), STRUCTURE_AND_RBAC.md (role model, guarded surfaces, code map), and DEV_SETUP.md (ADMIN_EMAILS steps, security, optional DB admin). Cross-links from `docs/auth/README.md`, `docs/auth/QUICK_REFERENCE.md`, and `docs/guides/CONTENT_PUBLISHING_GUIDE.md`. No Clerk org roles; admin is app-DB only.
- GPT 5.4 prompt guidance and content-agent audit. New `docs/guides/llm-prompt-guidance.md` (condensed output contract, verbosity, tool use, verification, grounding, personality, completeness). `docs/PROMPTS_IN_USE.md` and `docs/guides/content-agent-context.md` link to it. New `docs/guides/content-agent-prompt-audit.md` audits brief, draft, seo-aeo, and humanizer against that guidance with aligns, gaps, and recommendations.
- Article-shape validator and pipeline gates. `src/lib/content/agents/article-shape.ts`: list-to-prose ratio, sentence/paragraph length, flow, and topic-coherence checks with configurable thresholds. Orchestrator runs article-shape validation after draft and humanizer; failed runs get retry with injected feedback (`articleShapeRetryFeedback`). Content dry-run reports article-shape metrics and pass/fail in trace.
- Wisprs content audit skill. Reusable skill at `.cursor/skills/wisprs-content-audit/` (SKILL.md + AUDIT_CHECKLIST.md) to audit blog and pSEO content before publish: combined pSEO, copy/GTM, SEO-GEO, and AI SEO checks with a structured pass/fail report. Use when auditing any content asset for quality, keywords, CTAs, and GEO readiness.
- Content dry run and word-count enforcement. `scripts/content-dry-run.ts` runs the full content pipeline (brief → draft → seo-aeo → humanizer) for a blog input; shared `src/lib/content/agents/word-count.ts` enforces minimum words per template (reference-guide, core-software, etc.) in draft, humanizer, and orchestrator. Post-humanizer checks for word count and truncated bullets. Agent tools: `our-docs` and `web-search` in `src/lib/content/agents/tools/`. Docs: `docs/PROMPTS_IN_USE.md`, `docs/guides/content-agent-prompts.md`; `scripts/list-llm-model-priorities.ts` for inspecting seeded LLM priorities.
- Content agent LLM usage persisted to DB. All AI SDK usage from the content pipeline (brief, draft, seo-aeo, humanizer) is now stored in `content_agent_llm_calls`: provider, model, input/output/cache tokens, cost estimate, latency, finish reason, step count, tool calls/results summary, and optional job/stage/attempt context. Telemetry layer normalizes usage (prompt/completion vs input/output), and persist errors are logged without failing the pipeline. Run `npm run db:push` (or apply migrations) to create the table.
- Content agents v6 telemetry, structured outputs, and context docs. Full input/output telemetry for all content pipeline agents (brief, draft, seo-aeo, humanizer): shared `src/lib/content/agents/telemetry.ts` with `estimateCost`, `getTokenCount`, and `trackContentAgentLLMCall` (feature: `content-agent`, agent name, optional input/output char counts). All four agents instrumented with success/failure logging and metrics. Orchestrator: brief validation with `contentBriefSchema.safeParse` before store (invalid brief → job error); optional draft structure check (heading + min word count); SEO scores clamped 0–100. Docs: `docs/guides/content-agent-context.md` (context injection per agent, file paths, provider/model selection). Optional: `keywordMetadata` on brief agent input; `logLLMCall`/`trackLLMUsage` accept optional context for feature tagging; transcript summary calls use `feature: 'transcript-summary'` so transcript vs content-agent usage can be filtered in logs/metrics.
- Hybrid content platform for blog and pSEO pages. Added a shared content system that loads markdown blog content from `content/blog` and programmatic SEO programs from `content/pseo-programs`, with template-aware generation in `src/lib/content/`, a content ops dashboard at `/dashboard/content`, and an automation payload endpoint for AI workflows. The first live pSEO template families now include core software, podcast workflows, direct comparisons, alternatives lists, use cases, and free-tool pages.
- Wisprs publishing system docs. Added a long-form internal publishing SOP in `docs/guides/CONTENT_PUBLISHING_GUIDE.md`, template playbooks under `docs/guides/templates/`, and reusable working assets under `docs/guides/publishing-assets/` including the new-page kickoff template, brief template, prompt templates, and publish/refresh checklists.
Fixed
- Content job retry 500 (queue not initialized). The content job retry API (`POST /api/content/jobs/[id]/retry`) now calls `initializeQueue().catch(() => {})` before `getQueue()`, so retry no longer returns 500 when pg-boss has not been initialized (e.g. first hit after `next dev`). Matches the pattern used by the events track route.
- Retry button no visible response. The content queue review page Retry button now shows a loading state ("Retrying…") and disables while the request is in flight; on failure it shows an alert with the API error or status code; network errors are caught and surfaced so the user always gets feedback.
- Content Ops dashboard shows 0 when content_items empty. `getAllContentDocuments()` in `src/lib/content/queries.ts` now falls back to `loadPublishedBlogPostsFromFiles()` when the DB returns no items or throws, so the dashboard reflects file-based blog count (e.g. 15 articles) when the DB is empty or unsynced.
- Retry button on queued jobs. Retry is now shown only for in-progress or failed states (briefing, drafting, seo-check, humanizing, failed), not for queued jobs.
- Blog related posts null crash. When content DB queries failed and fallback returned null, `relatedPosts.length` threw. Blog post page now uses `(relatedPosts ?? []).length` and `(relatedPosts ?? []).map(...)` so the related-posts section renders safely when DB is unavailable or returns null.
- Blog FAQ Q/A display. Markdown renderer now treats FAQ blocks as both (1) `### Question?` with the answer in the next section and (2) `### Question?\nAnswer` in a single section (single newline), rendering both as distinct Q: / A: pairs with clear styling so FAQs no longer appear as plain H3 titles.
- Blog hero image 404 crash. Hero images under `/images/blog/` now use a safe fallback in `BlogPostCard` and blog `[slug]` page so missing assets never trigger 404s or malloc/crash; 14 placeholder images added in `public/images/blog/`.
- Content queries when DB schema is missing or fails. Blog list, post by slug, filters, related documents, and adjacent posts now have file-based fallbacks in `src/lib/content/queries.ts`, `db-queries.ts`, and `sync.ts` so the blog and related-docs sections work when `content_items` is missing or lacks expected columns (e.g. after a fresh clone). `getRelatedDocumentsFromDb` returns `null` on error instead of throwing; `console.error` in catch blocks was removed so failed DB lookups do not trigger the Next.js error overlay in development.
Changed
- Content list page visual pass and row separators. `/dashboard/content` now uses a table layout with a clear "Content" header strip, semantic columns (Title & path, Keyword, Status, Source, Actions), row hover state, and explicit horizontal borders between rows (`border-b border-white/20`) for clearer separation. Intro block tightened (section label, "Content" title, short subtitle); stat cards and action buttons use consistent card and button styles.
- Content edit: editor center stage, metadata in sidebar. Content edit page layout widened to `max-w-7xl`. Edit form uses a two-column layout: main area is the body (markdown) editor only with larger height (480px) and optional "Editing: {title}" context; right sidebar (sticky) holds Title, Description/Summary, Primary keyword, Category, Tags, Hero image URL (blog), secondary keywords/audience (pseo), and Save/Cancel. On narrow viewports the sidebar stacks below the editor.
- Content queue review on dedicated page. List at `/dashboard/content-queue` shows jobs only; review (rich markdown preview, Approve/Reject/Retry) moved to `/dashboard/content-queue/[id]`. Preview uses the same markdown renderer as the blog for WYSIWYG-style display. Back link returns to queue (optionally with same status filter).
- Content queue list UX. Jobs list uses two cards per row (`md:grid-cols-2`); each job card is compact (vertical stack, `p-3`, `gap-2`) with score chips and pipe separators for SEO/AEO/Voice, "View →" link affordance, and hover/focus on the link area. Stats cards and panel boundaries use stronger borders/backgrounds for separation.
- Refresh-blogs API and script support skip. `POST /api/content/jobs/refresh-blogs` accepts optional `?skip=N`; `scripts/refresh-blogs.ts` accepts a second argument `[skip]` (e.g. `npx tsx scripts/refresh-blogs.ts 10 5` for the next 10 after the first 5).
- Content agents: writing-style rules and GPT 5.4 alignment. Draft: added `<writing_style>` block (sentence/paragraph/word-level and copywriting principles) and article-shape retry feedback; explicit list budget (15–20 items total, max 25, <20% list lines). Humanizer: writing-style subsection in `voice-rubric.ts` plus article-shape retry and list discipline (preserve or reduce list count, never add). Brief: one line that the resulting draft will follow Wisprs writing standards. SEO-AEO: one line to preserve or improve writing style when optimizing. Wisprs content audit skill: AUDIT_CHECKLIST.md includes article-shape and structure checks.
- Article-shape thresholds tightened. `article-shape.ts`: listItemRatioMax 0.20, maxConsecutiveListItemsMax 4, maxListDominatedSections 0, minParagraphCount 10; articleShapeFeedback message strengthened with explicit list-count cap and prose-conversion guidance for retries.
- Content agents: template key threading and GEO freshness. Brief step derives `effectiveTemplateKey` from `brief.pageIdentity.templateKey` and threads it through SEO-AEO, humanizer, and min-words logic in the dry run. SEO-AEO system prompt includes “Updated [Month] [Year]” GEO instruction. Orchestrator and humanizer use shared word-count enforcement; post-humanizer truncated-bullet scan and trace warning in content-dry-run.
- Blog post and ops. `content/blog/5-tips-for-better-transcription-accuracy.md` updated (body and frontmatter). `.gitignore` excludes `.content-dry-run/`. Deploy and apply-migrations scripts updated; `scripts/seed-llm-models.ts` expanded; `docs/guides/content-agent-context.md` and devops deploy doc tweaks.
- SEO docs now match the live content architecture. Refreshed `project_docs/pseo/SYSTEM_OVERVIEW.md`, `project_docs/pseo/90DAY_PSEO_MASTER_PLAN.md`, `project_docs/pseo/KEYWORD_CLUSTERS.md`, `docs/SEO_STRATEGY.md`, and `docs/seo/KEYWORD_MASTER_CATALOG.md` so the source-of-truth docs reflect the actual route families, template keys, structured-data model, and hybrid blog+pSEO workflow. Older planning docs were marked as historical where appropriate.
- Public discovery surfaces follow the new route families. `src/lib/content/seo.ts`, `src/lib/seo/public-urls.ts`, `src/app/sitemap.ts`, and `src/app/llms.txt/route.ts` now understand the expanded route map and emit metadata, breadcrumbs, and public URLs consistently across blog, core, podcast, alternatives, use-case, and tools content.
Performance
- Homepage mobile performance and SEO/GEO improvements. Reduced the homepage hero’s mobile LCP cost by replacing the oversized PNG poster with a lightweight WebP poster (`public/videos/promo-hero-poster.webp`), tightening responsive image sizing, and preventing the promo video from loading on mobile, reduced-data, or reduced-motion contexts. Deferred GTM/GA4 bootstrapping until after the page load event so analytics no longer compete with first paint. Added a homepage canonical URL plus `SoftwareApplication` and `FAQPage` schema, and reused the shared FAQ source so on-page content and structured data stay aligned.
Performance
- Mobile LCP and critical-path improvements. Four targeted changes to improve mobile PageSpeed (baseline LCP 21.3s): (1) Hero image-first LCP: `product-mockup.tsx` now renders a `next/image` poster with `priority` and responsive `sizes` as the LCP element; the promo video gets `preload="none"` and is loaded + played only after the `window load` event fires, so mobile paints the poster immediately without waiting for video metadata. A crossfade makes the transition seamless. (2) Font preload trim: `src/lib/fonts.ts` — only Satoshi (body font) and Fredoka (hero heading + nav wordmark) are preloaded; the other 6 families (Quicksand, Doppio One, Mohave, Andika, Cal Sans, Space Grotesk) are now `preload: false` to eliminate unnecessary `<link rel="preload">` requests before first paint. (3) Hero inline style removed: `hero-section.tsx` inline `<style>` block replaced with equivalent responsive Tailwind classes (`sm:grid sm:grid-cols-[42fr_58fr]`, `text-center sm:text-left`) eliminating a render-blocking style injection. (4) Analytics deferred: `AnalyticsInit` and `TrackPageView` both gate on `window load` before bootstrapping Mixpanel/PostHog or sending page-view events, so analytics no longer compete with the LCP element on the critical render path.
- Homepage recovery follow-up: marketing shell, logo visibility, and fresh audits. Follow-up pass after the root-layout/Clerk crash: marketing pages now stay server-first while Clerk is scoped back to authenticated and pricing/auth layouts, the hero uses the real `public/videos/image.png` poster with the deferred `ProductMockupVideo` enhancer, and the powered-by strip uses the exact `public/logos/elevenlabs.webp` asset with corrected sizing so the logo is visible instead of collapsing into a blank chip. Fresh Lighthouse reruns showed the live site materially better than local dev: production scored about 75 mobile / 87 desktop, with the main remaining homepage bottlenecks now concentrated in mobile LCP and unused JavaScript rather than render-blocking third-party work or layout shift.
Changed
- Landing hero: promo video and two-column layout. (1) Video: Replaced static dashboard mockup with optimized promo video: transcoded source (`promo-clean.mp4`) to 1080p 30fps MP4, WebM, and poster via `scripts/transcode-hero-video.sh`; hero uses `<video>` with poster, preload=metadata, muted, autoPlay, playsInline, loop and WebM + MP4 sources in `product-mockup.tsx`. (2) Layout: Hero is two columns on desktop (CSS grid 42fr / 58fr at 640px+); text left, video card right; 16:9 aspect-video so landscape video fits the frame. (3) UI: Removed FeatureHighlight chat bubbles and Privacy First badge; reduced frame border and padding between columns; heading sizes tuned (lg/xl) so "AI transcription" stays on one line. Source video and script documented in SETUP.md; `promo-clean.mp4` / `promo.mp4` gitignored.
Added
- Verify transcript artifacts on prod: Runbook in `devops/WISPRS_DEPLOY.md`: run `scripts/verify-transcript-artifacts.ts [transcriptionId]` on the server (or with prod `DATABASE_URL`) to confirm artifact rows and `created_at`/`updated_at`; grep `journalctl -u wisprs` for `[artifacts-timing]` for processing-time metrics.
- ELEVENLABS_API_KEY in prod env sync: `scripts/sync-wisprs-prod-env.sh` now includes `ELEVENLABS_API_KEY` in the list of vars copied from `.env.local` to server `.env.production`; run the script and restart wisprs to fix 401 Invalid API key on prod.
- AI artifacts: storage verification, timing, pre-warm, and parallel preload. (1) Verify script: `scripts/verify-transcript-artifacts.ts` — run with `DATABASE_URL=... npx tsx scripts/verify-transcript-artifacts.ts [transcriptionId]` to confirm `transcript_artifacts` rows on prod (or list recent transcriptions by artifact count). (2) Timing logs: Extraction server actions (topics, chapters, action-points, minutes, summary, speakers, sales-call-kit) now log `[artifacts-timing]` with `cacheMs`, `chunksMs`, `llmMs`, `persistMs`, `totalMs` (or `cacheHit=true`) so latency can be attributed to cache, chunking, LLM, or persist. (3) Pre-warm: After a transcription completes (transcription worker, bridge completion poller, ElevenLabs webhook), we fire-and-forget generate summary, topics, and action points in parallel so the first tab open often hits cache. (4) Parallel preload: Transcript detail page preloads summary, topics, and action points on mount when the transcript is completed so switching to those tabs is often instant. See plan: AI-Extracted Data Storage and Latency.
- Mobile responsiveness overhaul. Marketing and dashboard are fully usable across devices. (1) Marketing: Mobile hamburger menu and slide-in drawer (Features, Use Cases, Pricing, FAQs, Blog, Sign in, CTA); hero and CTAs with 44px touch targets and stacked layout on small screens; features, pricing, FAQ, how-it-works, and footer with responsive grids and tap-friendly links. (2) Dashboard: Transcript detail layout stacks main content and sidebar on mobile (`flex-col lg:flex-row`); search/replace and tab pills wrap; dashboard pages use consistent `px-4 sm:px-6 lg:px-8` and `min-w-0` to prevent overflow. (3) Design skills: Installed and applied guidelines from web-design-guidelines, frontend-design, frontend-design-system, tailwind-design-system, and responsive-design. Public share viewer and settings/billing/translate/TTS pages audited for mobile.
- Branding: logos, favicon, and Open Graph image. (1) Logo: Wisprs W-ribbon + waveform lockup at `public/logos/wisprs-logo.png`; used in nav, footer, dashboard sidebar, share page, and email layout. (2) Favicon: `wisprs-favicon.png` (W-ribbon on dark) used for `public/favicon.ico`, `favicon-16x16.png`, and `favicon-32x32.png`; layout icons point to these. (3) OG image: `src/app/opengraph-image.png` (logo + wordmark + tagline “Transcribe Audio at the speed of AI.”); root layout `openGraph` and `twitter` metadata set with absolute image URL, title, description, and site URL. (4) Logo/wordmark alignment: Logo given a small downward nudge (`mt-1`) and gap between logo and wordmark reduced (`gap-1` / `space-x-1`) in nav, footer, sidebar, and share page for consistent lockup. Design concept and image-gen prompts in `project_docs/brand/LOGO_CONCEPT.md`.
Fixed
- Hero video never playing on desktop. The promo video only loaded when `(prefers-reduced-data: no-preference)` matched; that media query has poor support, so most desktops never got the video. `ProductMockupVideo` now only skips when `(prefers-reduced-motion: reduce)` or `(prefers-reduced-data: reduce)` matches, so the video plays on desktop by default and still respects accessibility preferences.
- Homepage client-side crash and missing ElevenLabs logo: Removed the broken global Clerk-provider pattern from the root marketing shell, restored route-scoped Clerk layouts (`dashboard`, `pricing`, `features`, auth flows), added server-rendered `PublicPricingCards` to keep marketing pages free of Clerk dependencies, and swapped the powered-by mark to the exact `public/logos/elevenlabs.webp` asset with a larger chip so the logo is visible on the dark homepage.
- Action Points "response did not match schema": Relaxed Zod schema in `src/lib/ai/action-points.ts`: `assignee`, `dueDate`, and `priority` use `.nullish()` so LLM responses with `null` pass; `completed` allows nullish; mapping normalizes nulls to `undefined` before persist. Added `[action-points] schema validation failed` logging (and `cause`/`issues`) in catch for prod debugging.
- Minutes tab "response did not match schema": Relaxed schema in `src/app/actions/transcript/minutes.ts`: `ActionItemSchema` owner/dueDate and `MinutesSchema` date/duration/arrays use `.nullish()` or `.nullish().default([])`; result is normalized before persist (nulls → defaults). Added `[minutes] schema validation failed` logging in catch.
- Paid users seeing "free tier" during transcription: Stream route `getStageMessage` now uses current user role: for bridge stages (`queued_free_tier`, `bridge_queued`, `bridge_processing`, `bridge_finalizing`), users with paid role (pro, studio, agency, enterprise, admin) see "Queued…" / "Transcribing with pro models…" / "Finalizing transcript…" instead of "free tier" copy. When the job actually uses ElevenLabs, existing "Transcribing with ElevenLabs..." is unchanged.
- Build: Mixpanel and webpack. (1) Mixpanel: Added `mixpanel` to `serverExternalPackages` and server webpack externals so the server-side events provider resolves at runtime instead of being bundled; eliminates "Can't resolve 'mixpanel'" during build. (2) retry-handler: Removed extra closing brace in `retry-handler.ts` that caused "Return statement is not allowed here" and broke compilation. (3) Clean `.next` before build avoids stale webpack chunk (e.g. missing `1073.js`) errors. `pnpm run build` now completes successfully.
- Queue not initialized on local: Event tracking (`POST /api/events/track`, `trackSignIn`) no longer throws "Queue not initialized. Call initializeQueue() first." when running only `next dev`. The events track route and the trackSignIn server action now call `initializeQueue()` before `trackEvent()` so the pg-boss queue is lazily initialized on first use when the DB is available; init errors are caught so requests still return 200 if the queue cannot start.
- Settings (and dashboard) Server Component error: `app/actions/events.ts` is a `'use server'` file and was exporting the string constant `SIGN_IN_TRACKED_KEY`; Next.js allows only async function exports from such files. Constant moved to `track-sign-in.tsx`, so the server action file only exports `trackSignIn`.
- Client-side crash on dashboard/transcribe (and all pages): Monitoring module no longer imports server `env`; it reads `process.env.NEXT_PUBLIC_MIXPANEL_TOKEN` (and optional PostHog vars) directly in the browser so the full Zod env schema is never run client-side (it requires server-only vars and would throw).
Added
- Slack on production: Docs for getting event and IndexNow Slack notifications in prod: add `SLACK_WEBHOOK_URL` to `.env.local`, run `./scripts/sync-wisprs-prod-env.sh`, restart wisprs. New "Slack on production" subsection in `docs/events/ENV_SETUP.md`; deploy runbook and production env template updated with sync-script reference.
- Mixpanel page views and sign_in events: (1) Page views/autocapture: Root layout now mounts `AnalyticsInit` client component so `@/lib/monitoring/unified` loads on every page; Mixpanel init (and session replay) runs site-wide instead of only when transcript/AI code is used. (2) Sign-in tracking: Server action `trackSignIn()` and dashboard-only client `TrackSignIn` send one `sign_in` event per session to the pipeline (Mixpanel, Slack, etc.); previously only `sign_up` (Clerk webhook) was sent.
- Slack: all events and rich messages: All 10 event types are sent to Slack (allowlist removed). Per-event formatting: emoji, bold title, ordered key props, and sales/debug footer (e.g. "Sales: new paid customer – Pro", "Debug: dashboard link + error"). Optional `reason` and `debug: { message?, error?, code? }` on events for Slack and logging only; user-facing UI and Resend emails stay generic. `notifyTranscriptionComplete(..., options?: { reason?, debug? })`; stuck cleanup, transcription worker, bridge poller, and `markTranscriptionFailed` pass failure reason/error so devs see it in Slack. Docs: `docs/events/TRACKING_PLAN.md` Slack section and "errors in Slack only, never in prod UI".
- Production env sync script: `scripts/sync-wisprs-prod-env.sh` reads `.env.local` with grep/sed and appends/updates event and analytics vars on the server `.env.production` (MIXPANEL*TOKEN, NEXT_PUBLIC_MIXPANEL_TOKEN, INDEXNOW*\*, NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_GA4_MEASUREMENT_ID, etc.). Client Mixpanel fix: `NEXT_PUBLIC_MIXPANEL_TOKEN` added so browser autocapture/session replay works (server still uses `MIXPANEL_TOKEN`).
- Unified event pipeline: Single `trackEvent()` entry point fans out to Resend, Slack, Mixpanel, GA4, IndexNow, and GSC via pg-boss `event` queue and `event-worker`. Event types: `transcription_started`, `transcription_ended`, `transcription_failed`, `sign_up`, `sign_in`, `checkout_started`, `checkout_completed`, `subscription_started`, `subscription_ended`. Notifications (queued/complete/failure) use `trackEvent()` with `emailRecipient` for Resend; call sites added in checkout create/success, Polar webhook, Clerk webhook. Docs: `docs/events/TRACKING_PLAN.md`, `docs/events/ENV_SETUP.md` (incl. testing Mixpanel and IndexNow); `.env.example` and ENV_SETUP updated for all provider vars.
- IndexNow: Optional `INDEXNOW_KEY` and `INDEXNOW_KEY_LOCATION`; key served at `GET /api/indexnow-key`. Events with `urls` submit to api.indexnow.org. Dev-only `GET /api/dev/indexnow-test?url=...` to test submission locally.
- Mixpanel: Server-side events via pipeline; client `mixpanel.init()` with autocapture and session replay (100%) when `MIXPANEL_TOKEN` set in env.
- GA4 client-side gtag: Optional `NEXT_PUBLIC_GA4_MEASUREMENT_ID`; when set, root layout injects gtag.js for page views. Server-side Measurement Protocol unchanged (`GA4_MEASUREMENT_ID` + `GA4_API_SECRET`).
- Bing Webmaster verification: `public/BingSiteAuth.xml` served at `/BingSiteAuth.xml` for site ownership verification in Bing Webmaster Tools.
- SEO and IndexNow: Dynamic sitemap, robots.txt, JSON-LD, llms.txt; shared public URL list; nightly IndexNow job (3 AM) with dry run and dev endpoint. Slack: Each run posts a summary to Slack when `SLACK_WEBHOOK_URL` is set. Logging: IndexNow runs use the unified logger (`feature: 'indexnow'`) and are persisted in the `indexnow_runs` table for audit and tracking.
- Stuck transcription kill and monitoring: (1) Automated cleanup: Scheduled job `stuck-transcription-cleanup` runs every 30 minutes and marks transcriptions in `processing` longer than `STUCK_TRANSCRIPTION_THRESHOLD_HOURS` (default 4) as failed, with a clear error so users can use Transcribe Afresh. (2) Manual cancel: `markTranscriptionFailed` server action (owner-only, pending/processing only) and Cancel button on transcription detail (linked-processing and new-processing) and on transcribe page queue. (3) Idempotency: Bridge completion poller and transcription worker only apply terminal state (completed/failed) when row is still `processing`, so user cancel or stuck cleanup is not overwritten by a late bridge response.
- ElevenLabs webhook secret verification: Optional `ELEVENLABS_WEBHOOK_SECRET` (wsec\_...) for verifying incoming async STT webhooks. When set, the handler validates the `elevenlabs-signature` header (HMAC-SHA256 of `timestamp.rawBody`), rejects missing or invalid signatures, and enforces a 30-minute replay window. Production template and `docs/guides/WEBHOOK_SETUP.md` document the secret; production URL set to `https://wisprs.co/api/webhooks/elevenlabs`.
- ElevenLabs API compliance: Optional `ELEVENLABS_STT_MODEL_ID` (scribe_v1 or scribe_v2); STT uses it with fallback to scribe_v1. Breaking-changes policy comments in STT/TTS response parsing (ignore unknown fields). Security subsection in WEBHOOK_SETUP (service accounts, key server-side only, voice permissions); `docs/integrations/ELEVENLABS.md` for STT model, enable_logging reference, and webhook/security links. TTS type fix: removed duplicate `permission_on_resource` on `ElevenLabsVoice`.
- Troubleshooting: Transcription email not received: `docs/guides/TROUBLESHOOTING.md` section "Transcription Email Not Received (Queued or Complete)" with flow, causes (Resend not configured, no user email, Resend error, worker not running), and verification steps; `docs/features/NOTIFICATIONS.md` links to it.
- Unified notification service (pg-boss + Resend): Notifications (email, in-app, webhook) now use pg-boss for durable delivery and retries instead of the in-memory queue. (1) `sendNotification()` enqueues to `QUEUE_NAMES.NOTIFICATION`; a notification worker (`src/lib/queue/notification-worker.ts`) processes each job and calls `sendNotificationsToProviders()`. (2) Job queued: When a transcription is enqueued (`processTranscription`), users receive an email and in-app event ("Transcription queued"); completion and failure continue to trigger "Transcription Complete" / "Transcription Failed" emails. (3) Resend idempotency: All Resend sends use a deterministic `idempotencyKey` (`wisprs-notif/${eventKind}/${transcriptionId}/...`) so pg-boss retries do not send duplicate emails. (4) Event kinds: `eventKind: 'job_queued'` and `eventKind: 'job_completed'` in notification payload for templates and future in-app UI. (5) Docs: Production env template and `docs/payments/ENV_SETUP.md` note verified domain for Resend; queue docs updated with `notification` queue. Plan: unified notification service (pg-boss, job queued, Resend compliance).
- Free-Tier STT Balance Plan (async-first model): Scalable free-tier STT operating model to protect server resources while preserving PLG. (1) Stream polling: SSE status poll interval increased from 500ms to 3s; stream session can stay open up to 60 minutes (no 10-minute cutoff); timeout message tells users they can leave and will be notified. (2) Async completion channel: Bridge completion poller queue (`bridge-completion-poller`) runs every minute; free-tier worker submits jobs to the bridge and returns immediately; poller applies completion/failure to DB and sends email/in-app notifications so completion is independent of browser session. (3) Free-tier hard caps: Per-file max duration (`FREE_TIER_MAX_FILE_DURATION_MINUTES`, default 30) with clear rejection copy; admission uses audio-minutes backlog and `FREE_TIER_AUDIO_MINUTES_PER_MINUTE` for estimated wait. (4) Notification-first UX: Stage messages and default copy updated so users know they can leave the page and will be notified when ready; completion email copy reinforces this. (5) Docs: Queue README links fixed (MANAGEMENT/ARCHITECTURE/SETUP → QUEUE_MANAGEMENT/QUEUE_ARCHITECTURE/QUEUE_SETUP); free-tier async model and `bridge-completion-poller` documented in `docs/queue/README.md`, `docs/queue/QUEUE_MANAGEMENT.md`, and `docs/setup/STT_PROVIDERS_AND_SETUP.md`. Plan reference: `.cursor/plans/free-tier_stt_balance_plan_bd15dfd8.plan.md`.
- Speed vs Quality upload option: At upload time users can choose Auto (recommended), Speed, or Best quality for transcription. Applies to free-tier self-hosted STT only: preference is stored in `transcriptions.metadata.sttQuality` / upload session metadata and passed to the bridge as `accuracyMode` (fast vs accurate). Simple upload (`POST /api/upload`) and chunked upload (init + upload-worker) both support the control; no backend or model names shown in the UI. See `docs/setup/STT_PROVIDERS_AND_SETUP.md`.
- Dual-backend STT bridge (spike): Bridge supports an engine abstraction with faster-whisper as default and optional NVIDIA ParaKeet TDT 0.6B v3. Optional request hint (`request.engine` / `routing.preferredEngine`); env-driven policy (`BRIDGE_STT_DEFAULT_ENGINE`, `BRIDGE_STT_ENABLE_PARAKEET`, `BRIDGE_STT_ENGINE_POLICY`, etc.). Result metadata includes `bridgeEngine`, `bridgeModel`, `bridgeMode`, `bridgeDevice`, `bridgeRoutingReason`. Doc: Faster Whisper vs ParaKeet comparison in `docs/features/STT_BRIDGE_SELF_HOSTED.md`; setup and env in `docs/setup/STT_PROVIDERS_AND_SETUP.md` and `stt-bridge/README.md`.
- LLM pipeline (OpenAI default mode): Seed and config aligned with docs pipeline: gpt-5-mini (chat, translation, extraction cost), gpt-5 (summary, Sales Call Kit, extraction quality), gpt-5-nano (locale detection). Seeded gpt-5-mini, gpt-5, gpt-5-nano; OpenAI preferred when no provider preference; translation and extraction no longer force Anthropic. See `docs/LLM_PRICING_AND_RECOMMENDATIONS_2026.md` and `docs/LLM_MODELS_AUDIT.md`.
- Transcription processing time: `transcriptions.processing_time_ms` column and index for query/sort/aggregate; worker sets it from bridge/provider metadata on completion (no backfill).
- Live status/duration without reload: Status stream sends `duration` and `processingTimeMs`; `TranscriptionDetailLive` client wrapper holds live transcription state and passes `onTranscriptionChange` from the tracker so File Information (Status, Duration) updates from the stream without page reload.
- Troubleshooting: "Transcription Failed: [Errno 111] Connection refused" in `docs/guides/TROUBLESHOOTING.md` with cause (bridge fetching audio URL) and fix (production `NEXT_PUBLIC_APP_URL=https://wisprs.co`).
- Deploy runbook: Nginx proxy buffers (post-deploy) and Wisprs rate-limit zones in `devops/WISPRS_DEPLOY.md`; production env template includes `BRIDGE_STT_URL`, `BRIDGE_STT_FILE_SECRET`, and note for `NEXT_PUBLIC_APP_URL`.
- Transcript artifacts: Added durable `transcript_artifacts` storage for transcript-derived AI outputs (`summary`, `chapters`, `action-points`, `minutes`, `topics`, `speakers`, `sales-call-kit`) with unique `(transcription_id, artifact_type)` upserts and DB relations.
- Pricing and subscription behavior (Polar): (1) Annual checkout — Pricing page toggle sends `interval: 'annual'`; checkout API uses annual product IDs (`POLAR_PRODUCT_ID_*_ANNUAL`); sync and limits treat annual subscriptions the same as monthly by plan. (2) Trials — Polar trials (e.g. 3d monthly, 7d annual) honored; we persist `polarStatus` and `trialEnd` in `subscriptions.metadata` and show "Your trial ends on …" on dashboard pricing when trialing. (3) Duplicate-purchase prevention — `POST /api/checkout/create` returns 409 `ALREADY_SUBSCRIBED` when the user has an active subscription, with optional `manageUrl` from `NEXT_PUBLIC_POLAR_CUSTOMER_PORTAL_URL`; pricing UI shows a dismissible banner and "Manage subscription" link. (4) Success-page pending UX — When Polar has not yet attached a subscription ID, verify-checkout returns `code: SUBSCRIPTION_PENDING`; success page shows "Setting up your subscription…" with Refresh and Go to Dashboard and one automatic retry. See `docs/payments/POLAR_COMPLIANCE_AND_SUBSCRIPTION_AUDIT.md` and `docs/payments/POLAR_SETUP_PRODUCTS_AND_WEBHOOKS.md`.
- Legal and info pages: Terms, Privacy, Cookie Policy, Data Protection, About, Changelog, Stats, and Status pages now have real content. Legal content in `src/content/legal/` (draft, aligned with founding docs and product: Clerk, Polar, Resend, ElevenLabs; no training on user content; Enterprise SOC2/GDPR/HIPAA); About from founding bible; Changelog reads repo `CHANGELOG.md`; Stats and Status with honest copy (no unsubstantiated metrics).
Security
- Redacted committed Redis password: Removed hardcoded Redis password from devops docs (OPERATIONS.md, CONFIGURATION.md, DEPLOYMENT.md, env.production.template); replaced with `YOUR_REDIS_PASSWORD` / “set in .env.production”. If that password was in use on winter-exodus, rotate it (redis-cli `CONFIG SET requirepass newpassword` and update .env.production).
Changed
- Homepage hero copy: Bullets reordered and updated in `marketing-copy.ts`: (1) Transcribe audio and video in 100+ languages; (2) Extract AI summaries, chapters, topics, and action items; (3) Export to TXT, SRT, VTT, MD, DOCX, or JSON; (4) Start for free. No credit card required. Tagline unchanged (99% accuracy and speaker recognition). Removed redundant "on most content" from hero and accuracy claims site-wide earlier in session.
- Codebase deslop (whole-codebase cleanup): Removed AI-generated noise: (1) Comments — Dropped ~120+ narrating comments and JSX section labels (e.g. `// Create`, `// Handle`, `{/* Step Circle */}`) across components (how-it-works-steps, hero-section, features-section, product-faqs, layout, navigation) and lib (external-chat, middleware, health route, languages-countries, clerk webhook, context-manager, validate-share, realtime-transcription, translation-interface, dashboard-sidebar-provider, share-transcript-modal). (2) Types — Replaced `as any` with proper typing: `getErrorStatus()` in error-handler/retry-handler; metadata segments typed via `Record<string, unknown>` + narrow in transcript-tabs and public-share-viewer; ShareSettingsBody and `Synthesis` cast in share-transcript-modal and text-to-speech page. (3) Try/catch — Removed defensive try/catch in storage upload (non-throwing); simplified user/workspace lookup in tts actions; documented fail-open in action-points/chat/summary routes; replaced console-only catches with short comments in auth middleware, events/index, queue-health, health/queues. Behavior unchanged; edits comments and types only.
- Nav and footer CTAs: Nav link "FAQ" renamed to "FAQs" (nav + footer). Nav primary CTA uses `NAV_CTAS` from `marketing-copy.ts`: "Log in" and "Transcribe For Free →" (matches hero); consistent button size (h-10, primary min-w-[180px]).
- Features page card footers: Feature cards on `/features` use flex layout so "Learn more →" is always pinned to the bottom of each card; grid item wrapper has `h-full flex flex-col`, card and CardHeader use `flex-1 min-h-0`, footer link has `mt-auto` for symmetrical footers across rows.
- Homepage "Powered by" strip: Centered below hero; shows OpenAI and ElevenLabs logos only (no "OpenAI Whisper" / "ElevenLabs Scribe" text); slash separator; no gray background on logo containers; ElevenLabs logo sized to compensate for asset inset; uses `public/logos/openai.svg` and `public/logos/elevenlabs.webp`.
- Homepage hero and copy: H1 "AI transcription software that gets it right."; short tagline "Speech to text with 99% accuracy on most content and speaker recognition."; pain/outcome-led bullets (99% accuracy, transcribe audio/video 100+ languages, start for free) from marketing-copy. Hero layout tightened so CTAs sit above the fold; trust line "Trusted by [avatars] creators and teams"; risk line "No credit card required. No commitment. Cancel anytime."; partner logos removed from hero. Blurred gradient separator added between hero and How it works.
- Features tracking and STT copy (Whisper + ElevenLabs Scribe): Added `project_docs/pseo/FEATURES_TRACKING.md` as single source for implemented features (STT engines, plan ladder, copy/SEO guidelines). Copy no longer implies only OpenAI Whisper: accuracy feature and FAQ answers now mention self-hosted Whisper-based models (free) and ElevenLabs Scribe (paid, scribe_v1/v2). Updated `src/lib/features-data.ts` (accuracy short/long/technicalDetails), `src/components/faq-section.tsx`, and `src/lib/faqs.ts`. Linked FEATURES_TRACKING from `docs/SOURCE_OF_TRUTH.md`.
- Features, use-cases, pricing, FAQ copy upgrade: Applied same copy policy and GTM keyword-led copy to /features, /use-cases, /pricing, /faq. marketing-copy.ts: features hero/meta use "99% accuracy on most content"; useCases hero keyword-led ("Speech to text for creators, teams, sales & enterprise"), subhead and new useCaseCards (Sales, Creators, Teams, Enterprise) with keyword-led descriptions; pricing hero subhead "Speech to text plans from free to team. 60 minutes free; then Pro, Studio, or Agency"; faq hero subhead "Transcription, pricing, security, and formats." Use-cases page now consumes copy.useCaseCards from module. features-data.ts: accuracy feature uses qualified accuracy wording. Pricing page: FAQ section H2/subhead keyword-led; exceed-limits answer tightened. faqs.ts: accuracy and exceed-limits answers aligned with policy. Wisprs copywriter skill: Added `.cursor/skills/wisprs-copywriter/SKILL.md` documenting the full iron-clad flow (brief, Seven Sweeps, GTM anti-slop, copy policy, implement in marketing-copy, quality check) for future copy work.
- Homepage copy audit (ruthless pass): Centralized trust and accuracy policy in `src/lib/marketing-copy.ts` (COPY_POLICY_TRUST_LINE, COPY_POLICY_FINAL_CTA, COPY_POLICY_ACCURACY). Hero: removed unsubstantiated "100k+"/"99,995" users; trust line "Trusted by creators and teams"; qualified "99% accuracy on most content"; added risk reducer "Free tier, no commitment. Cancel anytime." Features: H2 "Speech to text built for creators and teams"; sharper pain point and qualified accuracy in first card. Final CTA: "Join creators and teams who use Wisprs for accurate, fast transcriptions." How it works: benefit for Upload step; qualified accuracy in Transcribe; specific formats (TXT, SRT, VTT, DOCX) and outcome in Transform. Product mockup: "55+ pieces of content" → "Turn one recording into multiple assets." Footer, FAQ, and pricing: 99% qualifier site-wide; FAQ subhead and exceed-limits answer tightened; pricing subhead with keyword and plan names.
- SEO (sitemap and robots): robots.txt now disallows `/forgot-password`, `/reset-password`, and `/wisprs/` so flow and auth pages are not indexed; sitemap continues to list only public marketing/content. Comments in `app/robots.ts` and `lib/seo/public-urls.ts` document that sitemap and robots share the same indexing intent.
- Stuck transcription cleanup (threshold): Superseded by the [Unreleased] → Fixed entry: cleanup uses `updatedAt` again. A prior `createdAt`-based threshold incorrectly failed transcriptions that had been re-submitted after sitting in `processing` for days.
- Pricing CTA when logged in: Plan cards (Pro, Studio, Agency) now show "Upgrade" instead of "Get Started" when the user is signed in (Clerk); Enterprise keeps "Contact Sales", current plan shows "Current Plan". Implemented in `PricingCards` via computed `ctaLabel` from `isSignedIn`.
- Hide Free plan on marketing/features: Pricing cards on the homepage (`PricingSection`) and on feature pages (`FeaturePricing`, e.g. /features/accuracy) now show only paid plans (Pro, Studio, Agency, Enterprise); Free plan card is hidden on those pages.
- Email templates (Koala-style balance and spacing): (1) Line-height: `lineHeight: 22` → `'22px'` in paragraph/signOff so unitless value no longer renders as 22× font size. (2) Layout: Container padding `20px 40px 48px`, logoSection marginBottom 20px; full-width block CTA button (`display: 'block'`, `width: '100%'`). (3) Vertical spacing: Paragraph marginBottom 12px (and CSS override in wisprs-email-layout); buttonSection margin 20px; signOff marginTop 24 then 40px; greeting uses `greetingParagraph` (marginBottom 20) for salutation-to-body gap. (4) Design-skills-setup skill: `.cursor/skills/design-skills-setup/SKILL.md` documents install/use of frontend-design, web-design-guidelines, find-skills and Wisprs theme reference. (5) Preview and test: Dev-only `/dev/email-preview` page and `GET /api/dev/email-preview`; `scripts/send-test-email.ts` and `email:send-test` script for sending sample emails via Resend.
- Pricing (Studio & Agency): Studio $79/month (3,000 min STT, 90K TTS, 150K translation); Agency $149/month (5,000 min STT, 150K TTS, 250K translation). Limits and display copy updated in `limits.ts`, `pricing-data.ts`; founding and margin docs updated. Set Studio/Agency prices in Polar dashboard to match.
- .gitignore: Added `.env.production`, `.env.production.local`, `/audios/`, `*-dump*.sql`, `Untitled` so they are not tracked.
- Repo-wide (devops, scripts, app, lib): Devops runbooks and configs (DEPLOY, ITERATIVE_DEPLOY, PROD_ENV_AND_DB_CHECK, RECON_CHECKLIST, app docs, nginx/systemd configs, env templates); deploy script and check scripts; cancel-pgboss script; upload page, pricing, success page, multi-file-upload, stt-constants, db index, chunked-uploader, utils; Cursor wisprs-deploy skill; stt-bridge .gitignore and parakeet requirements; docs/plans and testing guide.
- STT bridge docs (server specs + performance): `devops/WISPRS_STT_BRIDGE.md` now has a "Performance and capacity" section tying server specs (winter-exodus: 2 CPU, ~4G RAM, 78G disk from RECON), exact bridge config (faster-whisper, small, fast, MemoryMax 1536M, CPUQuota 30%), and observed transcript runs (IDs 9 and 10) with a 4.5× processing/audio rule of thumb. `devops/RECON_RESULTS.md` Server hardware table filled via deploy SSH; `docs/features/STT_BRIDGE_SELF_HOSTED.md` links to the runbook for specs/config/performance.
- Language/Auto-detect icon: Replaced grid-style globe (🌐) with earth globe emoji (🌍) for Auto-detect and unknown language across transcriptions table, transcript tabs, stats cards, and language-display utils.
- Code cleanup (deslop): Removed redundant defensive code and noise from branch: upload-worker uses single `eq(storageKey)` after early throw (dropped dead `isNull` branch and import); ai-summary-panel uses inferred types in `.map()` callbacks; polar webhook logs `eventType` from one cast; button variant alias via lookup map; transcription-detail-live uses shared `cn` from `@/lib/utils`.
- STT timeouts for long files: App now uses a 20-minute minimum wait for bridge/self-hosted jobs (`STT_MAX_WAIT_SECONDS` in `src/lib/ai/stt-constants.ts`), and the stt-bridge source download timeout is configurable via `BRIDGE_DOWNLOAD_TIMEOUT_SECONDS` (default 600s / 10 min) so large files no longer fail with "timed out" during download.
- Upload then confirm transcription: After upload (simple or chunked), transcription is no longer started automatically. The user is redirected to the transcription detail page and sees "Ready to transcribe" with a mode selector (Auto / Speed / Quality) and a "Start transcription" button. The job is enqueued only when the user clicks Start; `metadata.queuedAt` prevents double-enqueue. Retranscribe / Transcribe Afresh still start immediately.
- Pricing & plans: `/pricing` aligned with founding docs; all five plans (Free, Pro, Studio, Agency, Enterprise) shown; FAQ copy updated for Polar and overage behavior.
- Features & product copy: Single source of truth documented in `docs/SOURCE_OF_TRUTH.md`; `features-data.ts` and `product-faqs.ts` aligned with per-plan entitlements (speaker ID on Pro+, export formats by plan, API copy for Agency/Enterprise); share page wording ("Full transcription features").
- Modes: Comments in `use-cases` and `modes` clarify only `default` and `sales` are app modes; use-case pages remain marketing.
- Accuracy claim: All "95% accuracy" references updated to "99%" across the app (features, hero, FAQ, pricing, footer, blog, product-faqs, modes, creators, layout) and `docs/features/STT_BRIDGE_SELF_HOSTED.md`.
- Features & Use Cases pages: Reduced hero and grid padding so content is visible above the fold (`py-24` → `py-10`, `mb-16` → `mb-8` on `/features`; same compact spacing on `/use-cases` and CTA section).
- Nginx proxy buffers: `devops/configs/nginx/wisprs.co` uses 256k / 8×256k / 512k for ~10K users and large Clerk session headers (avoids 502 after SSO).
- STT setup docs: Production must use public URL for `NEXT_PUBLIC_APP_URL` so bridge can fetch audio; link to Connection refused troubleshooting.
- AI artifact retrieval path: Transcript-derived AI outputs now use a shared `Redis -> Postgres -> generate` helper layer, with Redis backfill on DB hits and `forceRegenerate` overwriting both cache and DB.
Fixed
- Notification email subject: Strip any "[Wisprs test]" prefix from notification email subjects before sending so production emails never show it (only the test script adds that prefix; safeguard in `sendNotificationsToProviders`).
- Email CTA links: Notification emails now use transcription-specific URLs for the "View transcription" / "View dashboard" button when `transcription_id` is present (e.g. `https://wisprs.co/dashboard/transcriptions/13`). Redirect-after-sign-in already works (middleware + sign-in page `redirect_url`); no tokens in links. Doc: `docs/features/NOTIFICATIONS.md` "Email CTA links and auth" and optional Clerk magic-link note.
- Notification emails not sent (enqueue failed): pg-boss rejects job expiration >= 24 hours. Notification jobs in `src/lib/notifications/unified.ts` now use `expireInSeconds: 82800` (23h) so "Transcription queued" and completion emails are enqueued and delivered.
- Upload/transcribe "Authentication required" while logged in: (1) Middleware now allows GET `/api/transcriptions/:id/stream` through; auth is enforced in the stream route handler (Node runtime) via `getServerUser()`. (2) Upload routes (init, chunk, complete) return a clearer 401 message: "Session may have expired. Try signing out and signing in again." and log cookie presence when returning 401. (3) `getCurrentUser()` in `src/lib/auth/clerk.ts` logs non-PII diagnostics when returning null (no session, Clerk fetch failed, or DB sync failed). Plan: fix_upload_auth_401.
- TypeScript (tts, usage, export, webhooks, dashboard, components, queue/lib): (1) tts.ts: Use `Uint8Array(result.audioBuffer)` for `File` constructor (BlobPart). (2) usage.ts: Type `billingPeriod` from subscription metadata with explicit cast. (3) Export route: Use `BodyInit` (string or `Uint8Array(content)`) for `NextResponse` body. (4) Webhooks: Clerk — typed deleted user payload for `email_addresses`/`primary_email_address_id`; ElevenLabs — guard `currentSegment`, handle undefined `rawLangCode`; Polar — added `SubscriptionRenewedEvent` to `polar-types.ts` and union, fixed default/catch event type logging. (5) verify-checkout: Correct `syncSubscriptionFromPolarId(user.id, subscriptionId, productId)` and require `productId`. (6) Dashboard: TTS page — `Synthesis` allows `voice` null and `processingCost` string/number, display uses `Number()`; TTS [id] — `formatCost` accepts number/string/null; transcribe — pass `transcriptText ?? ''` to InlineTranscript; transcriptions [id] — typed `metadata`/`speakers` for TranscriptTabs; share — `shareData` guard, `transcriptText ?? ''`, discriminated `PublicShareViewerInitialData`, speaker list array guard. (7) Components: Button variants `outline`/`destructive`/`default`; Badge `outline`; MediaPlayer WaveSurfer `seeking` (not `seek`) and `currentTime`; removed duplicate local `cn` in action-points-panel, transcript-actions, transcript-sidebar, ai-summary-panel; dashboard-sidebar `minutesRemaining` as `typeof Infinity`; transcription-detail-live `fileUrl` on transcription type; transcription-status-tracker TranscriptTabs prop assertion; transcript-tabs `salesCallKitData ?? undefined`; sales-call-kit `copied` as `Record<string, boolean>`. (8) Queue/lib: transcription-worker — metadata via cast `meta`, bridgeMeta spread objects only, `queue.work` `batchSize`; upload-worker — `session.storageKey` guard, `eq`/`isNull` for storageKey, `batchSize`; bridge-completion-poller `batchSize`; transcription-deduplication status cast; chunk-manager `chunkData` cast for storeChunk; detect-locale `maxOutputTokens`; language-display and use-language-display string/null handling.
- TypeScript (pg-boss, scripts, upload, health): (1) getQueueCounts: pg-boss types do not declare `getQueueCounts`; added `getQueueCountsSafe()` in `src/lib/queue/index.ts` that uses runtime `getQueueCounts` or `countStates()` (with `created` → `pending` mapping) and returns empty counts on failure. Admission, queue-health, transcription-worker, and health/queues route now use `getQueueCountsSafe`. (2) Health routes: `pgbossStarted` explicitly typed as boolean. (3) Scripts: `fetch-real-models.ts` and `seed-llm-models.ts` — null guard in loop, cost fields converted to string for `llm_models` insert. (4) Upload routes: Added `src/types/busboy.d.ts` for busboy module; upload route uses `Writable` from `stream` for write stream and typed busboy/stream callbacks; chunk route callback types and `error: unknown` for busboy error handler.
- Transcription usage not decrementing: Pro/free users’ remaining minutes now decrease correctly after transcriptions. ElevenLabs webhook handler calls `incrementUsage` on completion (fire-and-forget); transcription worker uses `effectiveDurationSeconds` (result or job file-metadata fallback) so usage is incremented even when the provider omits `durationSeconds`. Fixes sidebar/billing showing full allowance (e.g. 1000/1000 min) despite completed jobs.
- Transcription detail UI: Status and Duration in File Information now update live from the status stream (via `TranscriptionDetailLive` + `onTranscriptionChange`); `router.refresh()` still runs on completed/failed.
- 502 after SSO: Resolved by increasing Nginx proxy buffer size on server (and in repo config) so large Clerk response headers are accepted.
- Database: `DATABASE_URL` in `.env.local` set to correct local PostgreSQL user so `users` and `transcriptions` queries succeed.
- Docs: `docs/founding/COMPLETION_STATUS.md`, `docs/founding/wisprs_founding_bible.md` pricing updated; `docs/README.md` links to SOURCE_OF_TRUTH and COMPLETION_STATUS.
Removed
- Dashboard sidebar: "Use Cases" link removed from dashboard nav (Use Cases remain on marketing site only).
- Better Auth: Removed from project; authentication is Clerk-only (no `BETTER_AUTH_*` env vars).
Added
- Free-tier STT bridge: Self-hosted FastAPI + faster-whisper service in `stt-bridge/`; free users routed to queue `transcription-free-self-hosted` and transcribed via bridge (no spill to ElevenLabs). Bridge supports fast (small) and accurate (large-v3) modes.
- Bridge client: `src/lib/ai/bridge-stt-client.ts` — submit job, poll status, fetch result; app passes `BRIDGE_STT_FILE_SECRET` so bridge can download audio from `/api/files/...` without Clerk.
- Routing source of truth: User tier from DB (`users.role`); upload-worker and transcription-worker use it for queue selection and provider choice. Paid users use ElevenLabs (transcription-priority).
- Docs: `docs/setup/STT_PROVIDERS_AND_SETUP.md` — single source of truth for OSS vs commercial STT, env vars, and how to confirm which provider processed a transcription (e.g. `node scripts/check-transcription-data.js <id>`).
- Script: `scripts/check-transcription-data.js` now prints `provider`, `provider_model`, and bridge metadata (`bridgeJobId`, `bridgeModel`, `bridgeMode`) for self-hosted jobs.
- Sales Call Kit fetcher: Implemented missing `fetchSalesCallKit` in `src/components/transcript-tabs.tsx` (was referenced in useEffect and onRegenerate, causing ReferenceError).
- Sales Call Kit schema: LLM often returns flat `subject`/`body` at root; added `FlatSalesCallKitSchema` in `src/lib/ai/sales-call-kit.ts` and map result to nested `followUpEmail` so validation passes.
- Error UX: Sales Call Kit failures (e.g. invalid API key) now trigger the app error boundary (Something went wrong / Try again / Go home) instead of inline error in the tab.
- Fixed TTS Usage Tracking: User ID now properly passed from TTS page to server actions
- Added `getCurrentUserId()` server action for client components
- TTS page now fetches and passes user ID to synthesis function
- Usage tracking now works correctly for all TTS operations
- Hardened STT Usage Tracking: Enhanced with transactions and validation
- Wrapped `incrementUsage()` in database transactions for atomicity
- Added input validation (userId, minutes)
- Added user existence checks before tracking
- Atomic SQL updates to prevent race conditions
- Comprehensive error logging
- Implemented Translation Usage Tracking: Complete translation character tracking
- Added `translation_characters_used` and `translation_count` columns to `usage_tracking` table
- Implemented `incrementTranslationUsage()` with transaction safety
- Added `calculateUsageFromTranslations()` function
- Integrated usage tracking in both standalone and transcription translation routes
- Added usage limit checks before translations (`checkTranslationUsageLimit()`)
- Updated `getBillingOverview()` to include translation usage
- Added translation limits to plan configuration (Free: 10K, Pro: 50K, Studio: 250K, Agency: 500K, Enterprise: unlimited)
- Universal Hardening: All usage tracking functions now use:
- Database transactions for atomicity
- Input validation (userId > 0, values > 0)
- User existence validation
- Atomic SQL updates (`sql` template) to prevent race conditions
- Error logging without breaking main operations
- Source-of-truth calculation from source tables when needed
- Build Fix: Extracted `TRANSLATION_LIMITS` to separate constants file
- Created `src/lib/constants/translation.ts` for client-safe constants
- Prevents Node.js module imports in client components
- Resolves "Module not found: Can't resolve 'net'" build error
- Text Length Validation: Comprehensive validation for translation text length
- API validation with clear error messages
- Maximum length: 10M characters (database limit)
- Empty text validation
- Character count display in UI
- Automatic Text Chunking: Intelligent chunking for long texts
- Chunks at sentence boundaries (preferred)
- Falls back to paragraph breaks, then word boundaries
- 500-character overlap for context preservation
- Automatic chunking for texts > 100K characters (Google) or > 50K characters (LLM)
- Chunked translations merged seamlessly
- UI Feedback & Warnings: Visual feedback for character limits
- Color-coded character counter (white/orange/yellow/red)
- Warning banners for large texts (> 100K chars)
- Error banners for texts exceeding database limit (> 10M chars)
- Translate button disabled when text exceeds limits
- Real-time character count updates
- Database Optimization: Length checks and safety measures
- Automatic truncation with `...[truncated]` marker for texts > 10M chars
- Length validation before database insertion
- Applied to both source and translated text
- Translation Limits Constants: Centralized limits configuration
- `GOOGLE_MAX_CHARACTERS`: 100,000 (Google Translate API limit)
- `LLM_MAX_CHARACTERS`: 50,000 (Conservative LLM limit)
- `DB_MAX_CHARACTERS`: 10,000,000 (Database practical limit)
- `CHUNK_OVERLAP_CHARACTERS`: 500 (Context preservation)
- Enhanced Copy/Download Functionality: Improved user experience
- Visual feedback for copy/download actions ("Copied!", "Downloaded!")
- Checkmark icons on success
- Proper error handling with user-friendly messages
- Disabled state when no translated text available
- Translation Documentation: Comprehensive documentation
- Complete translation service documentation (`docs/features/TRANSLATION_SERVICE.md`)
- Architecture diagrams and flow charts
- API endpoint documentation
- Usage examples and best practices
- Troubleshooting guide
- Public Pricing Page (`/pricing`): Complete pricing page with all plans
- Free, Pro ($25/month), Studio ($49/month), Agency ($99/month), Enterprise (Custom)
- Updated pricing to match final pricing docs (60 min STT free, 1,000 min Pro, 5,000 min Studio, 10,000 min Agency)
- TTS character limits displayed (5K free, 25K Pro, 150K Studio, 300K Agency)
- Premium voice caps displayed (10% Pro, 25% Studio, 35% Agency)
- Seamless authentication flow: logged-out users redirected to sign-in with plan preserved
- After authentication, redirects to dashboard pricing page
- Dashboard Pricing Page (`/dashboard/pricing`): Authenticated pricing page
- Shows current plan highlighted with usage progress
- Displays STT minutes and TTS characters used/remaining
- Plan cards with CTA buttons for checkout initiation
- Plan comparison information
- Billing Settings Page (`/dashboard/settings/billing`): Complete billing management
- Current plan overview with usage progress bars
- STT minutes and TTS characters usage breakdown
- Billing history table with order details
- Links to pricing page for plan changes
- Settings Index Page (`/dashboard/settings`): Settings hub
- Billing & Usage section linking to billing settings
- Account information display
- Updated Pricing Data Structure (`src/lib/pricing-data.ts`):
- Added Free plan (60 min STT, 5K chars TTS)
- Updated Pro plan: $25/month, 1,000 min STT (was 600), 25K chars TTS, premium voices ≤ 10%
- Updated Studio plan: $49/month, 5,000 min STT (was 2,000), 150K chars TTS, premium voices ≤ 25%
- Updated Agency plan: $99/month, 10,000 min STT (was 5,000), 300K chars TTS, premium voices ≤ 35%
- Added Enterprise plan: Custom $300+/month
- Updated Billing Limits (`src/lib/billing/limits.ts`):
- Updated plan limits to match pricing docs exactly
- Added TTS character limits for all plans
- Added premium voice caps (10%, 25%, 35%)
- Enhanced Pricing Cards Component (`src/components/pricing-cards.tsx`):
- Authentication-aware checkout flow
- Redirects logged-out users to sign-in with plan preserved
- For authenticated users: starts checkout directly from dashboard pricing page
- Shows current plan badge
- Displays STT/TTS limits and premium voice caps
- Handles Free and Enterprise plans appropriately
- Updated Dashboard Sidebar (`src/components/dashboard-sidebar.tsx`):
- Upgrade button now redirects to `/dashboard/pricing` instead of direct checkout
- Users can choose upgrade plan on pricing page
- Updated Checkout API (`src/app/api/checkout/create/route.ts`):
- Includes plan in success URL: `?checkout=success&plan={plan}`
- Adds plan to metadata for webhook processing
- Billing Actions (`src/app/actions/billing.ts`):
- `getBillingOverview()`: Fetches current plan, subscription, and usage (STT + TTS)
- `getBillingHistory()`: Fetches order history from `polar_orders` table
- Collapsible Sidebar: Desktop sidebar can be collapsed/expanded with smooth transitions
- Collapse button in header (chevron icon) toggles sidebar width
- Sidebar width: 224px (expanded) → 64px (collapsed)
- Logo and wordmark scale down to 75% when collapsed
- Navigation icons remain visible, text labels hide when collapsed
- Plan usage card hides when collapsed
- Collapsed state persists to localStorage
- Responsive Mobile Design: Mobile-optimized sidebar with overlay
- Hamburger menu button in main content area (top-left) when sidebar is closed
- Sidebar slides in from left with dark overlay backdrop
- Close button in sidebar header when open
- Touch-friendly tap targets and smooth animations
- Header Layout: Logo, wordmark, and collapse button in single row
- Logo and wordmark on the left
- Collapse/hamburger button on the right
- All elements vertically aligned in header row
- Smooth scaling animations for collapsed state
- Navigation Enhancements:
- Gradient divider lines between menu items for visual separation
- Settings menu item moved under Folders in navigation list
- User avatar and logout button in bottom section
- Avatar on left, logout button with text on right
- Layout adapts for collapsed state (vertical stack when collapsed)
- State Management: React Context provider for sidebar state
- `DashboardSidebarProvider` manages collapsed/expanded and mobile open/closed states
- LocalStorage persistence for collapsed state
- Context hooks for easy access throughout dashboard
- Visual Polish: Apple-level design consistency
- Smooth transitions (300ms duration) for all state changes
- Proper z-index layering for mobile overlay
- Responsive padding and spacing adjustments
- Consistent hover states and active indicators
Added
- Complete Subscription Billing System: Full integration with Polar.sh for subscription management
- Checkout session creation via Polar SDK (`@polar-sh/sdk`)
- Subscription lifecycle management (create, update, cancel, renew)
- Usage tracking and gating based on transcription minutes per month
- Feature-based access control based on subscription tier
- Database Schema: New tables for billing infrastructure
- `subscriptions` table: Tracks Polar subscriptions with status, periods, and metadata
- `usage_tracking` table: Tracks transcription minutes used per billing period
- Foreign key relationships with `users` table
- Optimized indexes for fast lookups
- Plan Limits Configuration: Centralized plan limits and features (`src/lib/billing/limits.ts`)
- Free: 30 minutes/month, basic features
- Pro: 600 minutes/month, $25/month, AI summaries, speaker diarization
- Studio: 2000 minutes/month, batch upload
- Agency: 5000 minutes/month, API access, team collaboration
- Enterprise: Unlimited minutes, all features
- Usage Tracking Service: Comprehensive usage tracking system
- Automatic increment after successful transcriptions
- Usage limit checks before allowing new transcriptions
- Period-based tracking with automatic reset at billing boundaries
- Real-time usage display in dashboard sidebar
- Feature Gating Middleware: Access control for transcription features
- `checkTranscriptionAllowance()` checks usage limits before uploads
- Integrated into upload worker to prevent over-limit transcriptions
- User-friendly error messages with upgrade CTAs
- Checkout Flow: Seamless checkout experience
- API endpoint: `POST /api/checkout/create` for creating checkout sessions
- Pricing cards component with checkout integration
- Success celebration with confetti and feature unlock display
- Resilient sync strategy: checks database first (webhook may have synced), then falls back to checkout session lookup
- Webhook Handler: Comprehensive webhook processing (`src/app/api/webhooks/polar/route.ts`)
- Signature verification using HMAC SHA256
- Idempotent event processing
- Handles all Polar events: `subscription.*`, `checkout.*`, `customer.*`, `order.*`
- Informational logging for non-critical events
- Error handling with graceful degradation
- Subscription Sync Logic: Shared sync function for webhooks and checkout
- `syncSubscriptionFromPolar()`: Core sync logic used by both webhooks and checkout
- Maps Polar product IDs to Wisprs plan names
- Updates user role in database
- Initializes/resets usage tracking for new billing periods
- Handles subscription updates, cancellations, and renewals
- Checkout Success Flow: Post-checkout verification and celebration
- `CheckoutSuccessCelebration` component detects success params in URL
- Checks database first (optimistic: webhook may have synced)
- Falls back to checkout session lookup if not found
- Shows "Processing Subscription" instead of "Failed" when webhook sync is expected
- 10-second confetti celebration with unlocked features display
- Auto-refresh of dashboard sidebar plan/usage info
- Dashboard Integration: Real-time plan and usage display
- Dashboard sidebar shows current plan, minutes used/remaining, usage percentage
- Upgrade CTA for free users or when usage >= 80%
- "Unlimited" display for enterprise plans (no "∞ / ∞ min" display)
- Refresh context for manual updates after checkout
- Environment Configuration: Polar environment variables
- `POLAR_ACCESS_TOKEN`: Polar API access token
- `POLAR_WEBHOOK_SECRET`: Webhook signature verification secret
- `POLAR_SERVER`: Environment selection (sandbox/production)
- `POLAR_PRODUCT_ID_PRO`, `POLAR_PRODUCT_ID_STUDIO`, `POLAR_PRODUCT_ID_AGENCY`: Product IDs
- `NEXT_PUBLIC_APP_URL`: Public app URL for redirects
- Worker Integration: Usage tracking in background workers
- Upload worker checks usage limits before allowing transcriptions
- Transcription worker increments usage after successful transcriptions
- Proper error handling and user feedback
- Error Handling: Comprehensive error handling throughout
- Expected 404 errors flagged (webhook may have processed checkout)
- User-friendly error messages
- Graceful fallbacks when webhook sync is delayed
- Comprehensive logging for debugging
Added
- Cryptographically Secure Share Links: Enterprise-grade sharing system with secure token generation
- 64-character hex tokens generated using `crypto.randomBytes(32)` for maximum security
- Unique, non-sequential tokens prevent link guessing and unauthorized access
- Share links format: `https://wisprs.com/share/{token}`
- Configurable Permission Levels: Granular access control for shared transcripts
- View Only: Read-only access to transcript text
- View + Download: Access to transcript and export functionality
- View + Edit: Full access including editing capabilities
- Permission-based UI rendering (hides features based on access level)
- Password Protection: Optional password protection for sensitive transcripts
- Bcrypt hashing (12 rounds) for secure password storage
- Rate limiting on password verification (5 attempts per 15 minutes)
- In-memory rate limiting to prevent brute force attacks
- Expiration Dates: Optional expiration dates for time-limited sharing
- Automatic link invalidation after expiration
- Clear error messages for expired links
- PLG Branding: Product-led growth features on public share pages
- Always-on branding (cannot be disabled per requirements)
- Animated waveform logo and wordmark matching app branding
- "Join Wisprs" CTA button with gradient styling
- "Powered by Wisprs" footer badge
- Feature highlights (multilingual, fast, accurate)
- Access Logging & Audit Trail: Comprehensive access tracking
- Logs all access attempts (successful and failed)
- Stores IP address and user agent for security auditing
- Access count tracking and last accessed timestamp
- Separate `shared_transcript_access_logs` table for detailed audit trails
- RBAC Integration: Role-based access control for sharing
- `canCreateShareLink`: Basic users can create view-only links for their own transcripts
- `canManageShareLinks`: Pro+ users can manage all share link settings
- `canShareWorkspaceTranscripts`: Studio+ users can share workspace transcripts
- Workspace-level permission checks for team collaboration
- Share Modal UI: Beautiful modal interface for managing share links
- Permission level selector using custom `Select` component (consistent with app design)
- Password toggle and input field
- Expiration date picker (optional)
- Link preview with copy button
- Access statistics (view count, last accessed timestamp)
- Revoke button to deactivate share links
- Real-time validation and error handling
- Public Share Page: Dedicated public viewing page for shared transcripts
- Server Component with direct database queries (no API round-trip)
- Custom error page for invalid/revoked/expired links (no Next.js 404 errors)
- Password prompt modal for protected links
- Full transcript display with all formatting options
- Timestamps view (default), Full Text view, and Speakers view
- Copy transcript button with gradient animation
- Export buttons (if permission allows)
- Responsive design matching app theme
- Dark mode enforced for consistent branding
- Database Schema: New tables for sharing infrastructure
- `shared_transcripts` table with indexes for performance
- `shared_transcript_access_logs` table for audit trails
- Foreign key constraints with cascade deletes
- Optimized indexes for fast token lookups
- API Endpoints: Comprehensive API for share management
- `POST/PATCH /api/transcriptions/[id]/share` - Create/update share links
- `GET /api/transcriptions/[id]/share` - Get share settings
- `DELETE /api/transcriptions/[id]/share` - Revoke share links
- `GET /api/public/share/[token]` - Public access endpoint with logging
- `POST /api/public/share/[token]/verify` - Password verification with rate limiting
- Security Measures: Enterprise-grade security features
- Secure token generation (cryptographically random)
- Password hashing with bcrypt (12 rounds minimum)
- Rate limiting on password verification
- Access logging for compliance and auditing
- Token uniqueness validation
- Expiration date enforcement
- Active/inactive status management
- Intelligent Paragraph Segmentation: Enhanced Full Text mode with smart paragraph detection
- Uses segments data (when available) to create natural paragraph breaks
- Breaks paragraphs on speaker changes (always), pauses > 2 seconds, and sentence boundaries
- Limits paragraphs to maximum 5 sentences to prevent walls of text
- Falls back to speaker data or sentence-based detection when segments unavailable
- Improved Visual Formatting: Better typography and spacing for readability
- Increased paragraph spacing from `mb-6` to `mb-8` for better visual separation
- Improved line height from `leading-7` to `leading-8` for easier reading
- Added max-width constraint (`max-w-4xl mx-auto`) for optimal reading width
- Centered layout with constrained width instead of full-width text dump
- Optimized for Long Transcripts: Enhanced paragraph grouping for 3+ hour transcripts
- More intelligent segment grouping based on timing data
- Better sentence detection and grouping (2-3 sentences per paragraph in fallback mode)
- Maintains performance with long transcripts
- Clean Text Display: Full Text mode now shows clean, readable paragraphs
- No timestamps or speaker labels (unless those modes are enabled)
- Proper paragraph breaks for easy reading and digestion
- Works seamlessly with search highlighting functionality
- Database-Driven Language Display: Replaced hardcoded language code mappings with database-driven language names and country flag icons
- Created centralized utility module (`src/lib/utils/language-display.ts`) for server-side language display
- Created client-side hook (`src/hooks/use-language-display.ts`) for React components
- All language codes (ENG, EN, etc.) now display as full language names with flag emojis (e.g., "🇺🇸 English" instead of "ENG")
- Uses existing languages and countries cache infrastructure for fast lookups
- Graceful fallbacks for null/undefined/unknown language codes
- Component Updates: Updated all transcription module components to use new language display system
- `TranscriptionsTable`: Language column shows flag + name
- `TranscriptTabs`: Language badge shows flag + name
- `TranscriptionStatsCards`: Top language card shows flag + name
- `TranscriptionDetailPage`: Language display shows flag + name
- `TranscriptionFilters`: Language filter dropdown shows flag + name for each option
- Non-Breaking Changes: All function signatures maintained, same fallback behavior, pure UX improvement
- Unified Timestamp Styling: All timestamps across transcript tabs now use consistent pink color (`#EC4899`)
- Transcript view timestamps (when Timestamps toggle enabled)
- AI Summary timeline highlights
- Chapters panel time ranges
- Topics panel duration displays
- All timestamps use `font-medium` for visual consistency
- Unchecked by Default: Action points now always start unchecked regardless of LLM output
- Updated LLM prompt to always set `completed: false`
- Added explicit mapping to force `completed: false` after extraction
- Cache retrieval also resets completed status to false
- Users can mark action points as completed using existing checkbox toggle
- Better UX: action points start as actionable items, not pre-completed
- Client-Side Caching: Implemented intelligent client-side state management for all AI-generated content
- All AI data (Summary, Chapters, Minutes, Topics, Speakers, Action Points) cached in parent component
- Data persists across tab switches for instant display (no re-fetching)
- Eliminates unnecessary LLM API calls when switching between tabs
- Expected 80%+ reduction in API costs for tab navigation
- Smart Tab Switching: Intelligent data fetching based on cache state
- Only fetches data when tab becomes active AND data doesn't exist
- Instant display when data is already cached
- Loading overlay only shows when actually fetching
- Regenerate Functionality: Manual refresh option for all static tabs
- Regenerate buttons added to Summary, Chapters, Minutes, Topics, Speakers, Action Points tabs
- Positioned in top-right corner of each panel
- Bypasses both client-side and server-side cache
- Shows loading state during regeneration
- Chat tab remains dynamic (no regenerate needed)
- Loading Overlay Enhancement: Improved loading UX with full dark background
- Changed from semi-transparent (`bg-black/40`) to full dark (`bg-black/90`)
- Increased z-index to `z-50` for proper layering
- Centered preloader with tab-specific messages (e.g., "wisprn' the summary...")
- Covers entire content area for better visual feedback
- Server-Side Cache Bypass: Added `forceRegenerate` parameter to all Server Actions
- `generateChapters()`, `generateMinutes()`, `extractTopics()`, `generateSpeakerSummary()`
- `generateSummary()`, `extractActionPoints()`
- When `forceRegenerate` is true: skips Redis cache, deletes existing cache, generates fresh data
- Performance Optimizations: Render optimization with React hooks
- All fetch functions use `useCallback` to prevent unnecessary re-renders
- Proper dependency arrays for memoization
- Optimized state management to reduce component re-renders
- Speaker Information in Summaries: Enhanced summary generation with speaker context
- Summary generation now receives speaker data from ElevenLabs diarization
- Speaker count correctly displayed in AI Summary panel
- Speaker names/IDs included in chunked context for better LLM analysis
- Context manager includes speaker information when available
- Improved summary quality with speaker attribution
- Custom Select Component Integration: Replaced native `<select>` dropdown in media player with custom `Select` component
- Consistent styling with transcript filters dropdowns
- Gradient highlight for selected playback speed option
- Improved keyboard navigation and accessibility
- Matches design language across all dropdowns in the application
- Visual Balance Improvements: Enhanced transcript view height matching with sidebar
- Implemented flexbox-based layout using `items-stretch` for natural height matching
- Added max-height constraint (`calc(100vh-150px)`) for optimal scrollbar behavior
- Transcript text area now fills available space while respecting max-height
- Eliminated bottom gap between transcript card and sidebar
- Both containers now align perfectly for Apple-level visual balance
- Search & Replace: Split search input into two side-by-side fields
- Left input: "Search in transcript..."
- Right input: "Replace with..."
- "Replace" button appears when both fields have content
- Enter key support in replace field
- Shortcut Icons: Added quick action buttons above transcript text
- Reordered buttons: All, Full Text, Timestamps, Speakers, Translate
- Translate button moved to end of button row
- Timestamps toggle (show/hide timestamps with active gradient state)
- Full Text toggle (show/hide full text view with active gradient state)
- Speakers toggle (show/hide speaker labels with active gradient state)
- All buttons use rounded-full styling to match tab pills
- Active states use gradient background (purple → pink → orange)
- Timestamp State Synchronization: Sidebar checkbox syncs with main tab pill
- "Show Timestamps" checkbox in sidebar reflects main tab state
- Toggling either control updates both UI elements
- Consistent state management across transcript view
- Language Display: Enhanced language column to show full language name and flag icon
- Displays flag emoji (🇺🇸, 🇪🇸, etc.) alongside full language name
- Example: "🇺🇸 English" instead of "EN"
- Added `getLanguageFlag()` and `getLanguageName()` utility functions
- Created reusable `Select` component to replace native `<select>` elements
- Ensures Cal Sans font is applied to all dropdown options
- Used in transcription filters (Status, Date Range, Language, File Type)
Added
- Statistics Cards: Added 4 stat cards above the table
- Transcription Volume (total count, last 30 days)
- Status Distribution (total transcriptions by status)
- Top Language (most transcribed language with emoji flag)
- Avg Processing Time (average processing time in minutes)
- Advanced Filtering: Comprehensive filter bar with multiple options
- Search input for file name search
- Status filter (All, Completed, Processing, Pending, Failed)
- Date range filter (All time, Last 7/30/90 days)
- Language filter (dynamically populated from transcriptions)
- File type filter (All, Audio, Video)
- Sortable Table View: Full-featured table with column sorting
- Sortable columns: File Name, Status, Date, Duration, File Size, Language
- Visual sort indicators (arrows) showing current sort field and direction
- Sorting works on full filtered dataset before pagination
- Language display shows full name with emoji flag (e.g., "🇺🇸 English")
- View Toggle: Switch between table and grid card views
- Table view (default): Sortable, paginated table
- Grid view: Card-based layout using existing TranscriptionCard component
- Pagination: User-selectable pagination with controls
- Items per page: 10, 20, 50, 100
- Previous/Next navigation buttons
- Page counter display
- Automatic reset to page 1 when filters or sorting change
- Custom Select Component: Replaced native `<select>` elements with custom styled dropdowns
- Full dark theme support for dropdown options
- Keyboard navigation (Arrow keys, Enter, Escape)
- Click outside to close
- Selected state with checkmark and gradient highlight
- Smooth animations and transitions
- Proper ARIA attributes for accessibility
- Loading State: Added waveform logo animation to loading state
- Added "Changelog" link to Product column
- Added "Blog", "About Us", "Stats", and "Status Page" links to Company column
- Added "Data Protection" and "Cookie Policy" links to Legal column
- Moved "FAQ" from Company to Product column
- Removed "Dashboard" link from Product column
- Empty State Card: Redesigned upload interface with centered icon, text, and button
- Icon positioned near top with proper spacing
- Centered text and call-to-action button
- "Supported formats" section at bottom center
- Full-page drag-and-drop functionality
- New Transcription Button: Added button in top right of transcribe page that navigates to `/dashboard/upload`
- Upload Page: Redesigned to match transcribe page empty state design
- Server-Sent Events (SSE): Implemented real-time transcription status updates
- Endpoint: `/api/transcriptions/[id]/stream`
- Auto-reconnection with exponential backoff
- Progress tracking and status updates
- Transcription Queue Component: Displays active (pending/processing) transcriptions
- Real-time status updates
- Progress indicators with gradient colors
- Cancel and view options
- Transcription Error Component: User-friendly error messages with retry functionality
- Transcript Tabs Component: Pill-style tabs for different views
- Transcript tab with search functionality
- AI Summary tab
- Wisprs Chat tab (renamed from "Chat with Audio")
- Action Points tab
- Chapters tab
- Minutes tab
- Speaker Summary tab
- Topic Summary tab
- Vertical separators between tab pills
- Two-row layout with horizontal divider
- AI Summary Panel: Comprehensive AI-generated summary
- Stats section (Word Count, Characters, Speakers, Duration, Sentiment with emoji)
- Timeline Highlights with pink timestamps
- Topics section with gradient badges
- Key Points with light gray background cards
- All sections properly vertically aligned
- Wisprs Chat Panel: Interactive chat interface for transcript Q&A
- Message history
- Suggested questions
- Mock AI responses (structured for API integration)
- Action Points Panel: Extractable action items
- Checkbox list with completion tracking
- Assignee and due date support
- Export functionality
- Chapters Panel: Automatic chapter detection
- Chapter titles and summaries
- Time range badges with pink timestamps
- Minutes Panel: Meeting minutes extraction
- Date and duration
- Attendees list
- Agenda items
- Key decisions
- Action items with owners and due dates
- Speaker Summary Panel: Speaker analysis
- Speaker names, roles, and speaking times (pink timestamps)
- Speaking time percentages with progress bars
- Key points per speaker
- Topic Summary Panel: Topic analysis
- Topic names with mention counts and durations (pink timestamps)
- Percentage of total with progress bars
- Key insights per topic
- Waveform Player: Integrated `@wavesurfer/react` for audio/video playback
- Waveform visualization with gradient colors
- Playback controls (play/pause, seek, volume, mute, speed)
- Current time and duration display
- Video element support for video files
- Compact, minimal Apple design
- Horizontal divider above player
- Custom Select for Playback Speed: Replaced native dropdown with custom `Select` component
- Consistent with transcript filter dropdowns
- Gradient highlight for selected option
- Improved accessibility and keyboard navigation
- Transcript Sidebar: Utility actions in collapsible sections
- Export Section (collapsible):
- Copy Transcript (enhanced with gradient animation and toast feedback)
- Download TXT
- Download SRT
- Download DOCX
- Download MD (Markdown export)
- Download VTT
- Advanced Export (highlighted with gradient)
- More Section (collapsible):
- Show Timestamps (toggle, syncs with main tab pill)
- ChatGPT integration
- Claude integration
- Translate
- Share Transcript (opens share modal with RBAC controls)
- Download Audio (downloads original audio/video file)
- Rename File
- Delete Transcription (with confirmation)
- Copy Transcript Enhancement: Improved UX with visual feedback
- Gradient background animation on copy (purple → pink → orange)
- Animated checkmark icon with draw animation
- Temporary toast notification: "Copied to clipboard!"
- Smooth transitions and state management
- Uses app gradient colors instead of generic green
- Download Audio Functionality: Direct download of original files
- Handles various file URL formats (relative, absolute, API routes)
- Proper blob handling and download trigger
- File size display in button
- Error handling for missing files
- Markdown Export: New export format for documentation
- Supports timestamps and speaker labels (optional)
- Clean paragraph formatting
- Compatible with Markdown viewers and documentation tools
- Added Status column to File Information grid
- Moved status badge from header to File Information card
- Added vertical divider between "Created" and "Completed" timestamps
- Applied gradient colors to timestamp labels ("Created" and "Completed")
- Removed timestamp from transcript header
- Added language flag emoji to language badge
- Full language name display (e.g., "English" instead of "en")
- Gradient Colors: Applied throughout UI
- Progress bars use gradient: `from-[#8B5CF6] via-[#EC4899] to-[#F59E0B]`
- Processing spinners use gradient colors
- Active tab pills use gradient background
- Waveform visualization uses gradient
- Typography: Harmonized all body text to use `calSans` font
- Color Consistency: All timestamps use pink color (`#EC4899`)
- Vertical Alignment: Fixed all list items to be vertically centered
- Timeline Highlights
- Key Points
- Agenda items
- Speaker key points
- Topic insights
- Action points checkboxes
- Reduced padding on transcription detail page to match upload page
- Increased max-width to `max-w-7xl` to accommodate sidebar
- Proper spacing between tab rows
- Compact media player design
- Transcript View Height Optimization:
- Flexbox-based height matching between transcript card and sidebar using `items-stretch`
- Max-height constraint (`calc(100vh-150px)`) for optimal scrollbar behavior
- Natural height matching without forced viewport calculations
- Eliminated visual gaps and improved visual balance for Apple-level design
Changed
- Updated app name capitalization from "WISPRS" to "Wisprs" across entire application
- Enforced full dark mode theme across public share pages
- Consistent animated waveform logo and wordmark across all pages
- Gradient color scheme applied to metadata labels and timestamps on share pages
- Custom error pages for invalid/revoked/expired share links
- Replaced Next.js 404 errors with user-friendly error messages
- Consistent branding and design on error pages
- Clear action buttons (Go Home, Get Started Free) on error pages
- Converted to Client Component for interactivity
- Replaced native `<select>` elements with custom `Select` component
- Updated grid cards to use Cal Sans font for all text elements
- Fixed sorting to work on full dataset before pagination
- Improved language display with full names and emoji flags
- Fixed processing time calculation to prevent negative values
- Enhanced dropdown styling consistency across all filters
- Changed "Back to Dashboard" link to "Back to Transcripts" (navigates to `/dashboard/transcriptions`)
- Updated both "Created" and "Completed" timestamps to show full date and time
- Format: "November 23, 2025 at 07:28 AM" for both timestamps
- Replaced inline transcript component with tabbed interface
- Moved utility buttons from header to sidebar
- Removed download button from header (now in sidebar)
- Removed edit button (as per user request)
- Status badge moved from header to File Information card
- Language display shows full name with flag emoji
- Updated error page (`src/app/error.tsx`) to use Cal Sans font
- Updated not-found page (`src/app/not-found.tsx`) to use Cal Sans font
- Updated transcription error component to use Cal Sans font
- Updated delete button to use Cal Sans font
- Updated folders modal to use Cal Sans font
- All error messages now properly wrap text with `break-words` and `whitespace-pre-wrap`
- Renamed "Chat with Audio" to "Wisprs Chat"
- Moved "Copy Transcript" from tab row to sidebar Export section
- Replaced "Copy" button with "Chapters" button in tab row
- Added "Minutes" tab after "Chapters"
- Added "Speaker Summary" and "Topic Summary" tabs after "Minutes"
- All tabs display in two rows with horizontal divider
- Moved "Stats" section above "Timeline Highlights"
- Moved "Timeline Highlights" and "Topics" above "Key Points"
- Added emoji beside sentiment value
- Converted "Key Points" to light gray background cards (matching Timeline Highlights)
- Added "Characters Count" and "Speaker Count" to Stats section
- Changed Stats grid from 3 columns to 5 columns to prevent wrapping
Removed
- Removed "Dashboard" link from footer Product column
- Removed "New Transcription" button from transcribe page header (later re-added with navigation)
- Removed "We support MP3, MP4, WAV, M4A, and more." line from empty state
- Removed `MultiFileUpload` component from transcribe page (user requested single upload interface)
- Removed download button from transcription detail page header
- Removed edit button from sidebar
- Removed timestamp line from transcript header
- Removed inline graphs from statistics cards (per user request for cleaner design)
Changed
- Server Component with direct database queries (no API round-trip)
- Custom error handling for invalid/revoked/expired links
- Password protection modal for secure links
- Default view set to "Timestamps" for better initial experience
- Paragraph formatting matching Full Text tab in dashboard
- Gradient colors for metadata labels and timestamps
- Permission badge with dynamic colors (yellow/orange/green)
- Waveform logo integrated into CTA buttons
- Full dark mode theme enforcement
- State Management: Refactored all AI panel components to accept props instead of fetching internally
- `ChaptersPanel`, `MinutesPanel`, `SpeakerSummaryPanel`, `TopicSummaryPanel` now receive data as props
- `AISummaryPanel`, `ActionPointsPanel` updated to accept cached data
- Removed local `useState` and `useEffect` for data fetching from panel components
- Centralized state management in parent `TranscriptTabs` component
- Loading States: Improved loading state management
- Separate loading states for each tab (prevents cross-tab loading interference)
- Regenerating flags to distinguish between initial load and regeneration
- Error states properly isolated per tab
- Cache Strategy: Two-layer caching implementation
- Client-Side: Component state in parent (persists for session, instant display)
- Server-Side: Redis cache with 24h TTL (fast subsequent loads)
- Cache check order: Client state → Redis cache → LLM generation
- Speaker Count Display: Fixed to show actual speaker count from ElevenLabs diarization
- Uses `summary.speakerCount` or falls back to `transcription.speakerCount`
- No longer shows "0" when speakers are detected
- Context Passing: Enhanced to pass speaker information to LLM
- Speaker data included in chunked context
- Speaker names/IDs included in prompt for better analysis
Fixed
- Fixed React hydration mismatch in `WaveformBackground` component
- Client-only rendering with `useState` and `useEffect`
- Rounded floating-point values to prevent precision differences
- SSR placeholder to avoid server/client HTML mismatch
- Eliminated hydration warnings in browser console
Technical
- `src/components/empty-state-card.tsx` - Full-page upload interface
- `src/components/transcription-queue.tsx` - Active transcriptions display
- `src/components/transcription-error.tsx` - Error handling component
- `src/components/inline-transcript.tsx` - Inline transcript display (replaced by tabs)
- `src/components/transcript-tabs.tsx` - Tabbed transcript interface with search/replace and shortcut icons
- `src/components/transcript-sidebar.tsx` - Utility actions sidebar
- `src/components/media-player.tsx` - Waveform audio/video player
- `src/components/ai-summary-panel.tsx` - AI summary display
- `src/components/chat-with-audio.tsx` - Chat interface (renamed to WisprsChatPanel)
- `src/components/action-points-panel.tsx` - Action points extraction
- `src/components/multi-file-upload.tsx` - Multi-file upload (removed from transcribe page)
- `src/components/select.tsx` - Custom select dropdown component with Cal Sans font support
- `src/components/share-transcript-modal.tsx` - Share link management modal with RBAC
- `src/components/public-share-viewer.tsx` - Public transcript viewer for shared links
- `src/components/waveform-background.tsx` - Animated waveform background watermark
- `src/components/toast.tsx` - Toast notification component
- `src/app/api/upload/route.ts` - File upload endpoint
- `src/app/api/transcriptions/[id]/stream/route.ts` - SSE status updates
- `src/app/api/transcriptions/[id]/export/route.ts` - Export transcript in various formats (TXT, SRT, VTT, DOCX, JSON, MD)
- `src/app/api/transcriptions/[id]/share/route.ts` - Share link management (create, get, update, delete)
- `src/app/api/public/share/[token]/route.ts` - Public access endpoint with access logging
- `src/app/api/public/share/[token]/verify/route.ts` - Password verification with rate limiting
- `src/app/api/transcriptions/[id]/route.ts` - Added DELETE handler for transcription deletion
- `src/app/api/transcriptions/[id]/export/route.ts` - Added Markdown (MD) export format
- `src/hooks/use-transcription-status.ts` - SSE connection hook
- `src/lib/storage.ts` - Storage abstraction layer (S3/R2 compatible)
- `src/lib/utils.ts` - Added `formatTimestamp()` utility function for consistent date/time formatting
- Supports multiple formats: `full`, `short`, `compact`
- Optional time inclusion
- Used across transcription detail pages and components
- `src/lib/sharing/generate-token.ts` - Cryptographically secure token generation
- `src/lib/sharing/validate-share.ts` - Share token validation and permission checking
- `src/lib/sharing/permissions.ts` - RBAC permission checking for sharing features
- `uploadAndQueueTranscription()` - Combined file upload and transcription creation
- `batchCreateTranscriptions()` - Handle multiple file uploads
- `getTranscriptSummary(transcriptionId, forceRegenerate?)` - AI summary with caching and regeneration support
- `chatWithTranscript()` - Interactive chat with transcript (remains dynamic, no caching)
- `extractActionPoints(transcriptionId, forceRegenerate?)` - Action points extraction with caching
- `getTranscriptionById()` - Enhanced to join with `transcriptionFiles` for `fileUrl`
- `generateChapters(transcriptionId, forceRegenerate?)` - Chapters generation with cache bypass
- `generateMinutes(transcriptionId, forceRegenerate?)` - Meeting minutes with cache bypass
- `extractTopics(transcriptionId, forceRegenerate?)` - Topics extraction with cache bypass
- `generateSpeakerSummary(transcriptionId, forceRegenerate?)` - Speaker summaries with cache bypass
- `generateSummary(options)` - Added `forceRegenerate` option, enhanced with speaker context
- `extractActionPoints(options)` - Added `forceRegenerate` option for cache bypass
- All AI functions now support cache invalidation and regeneration
- `formatMD()` - New Markdown formatter in `src/lib/export/formatters.ts`
- Supports optional timestamps and speaker labels
- Clean paragraph formatting for documentation
- Compatible with Markdown viewers and documentation tools
- Enhanced transcription queries to include file URLs and storage keys
- Added support for metadata storage (duration, language, etc.)
- New Tables for Sharing:
- `shared_transcripts` - Share link configuration and settings
- `shared_transcript_access_logs` - Access audit trail with IP and user agent
- Optimized indexes for fast token lookups and access queries
Design Principles Applied
- KISS (Keep It Simple): Zero-config defaults, minimal user input
- DRY (Don't Repeat Yourself): Reusable components and patterns
- SOLID: Single responsibility components, clean interfaces
- FAANG UX Patterns:
- Progressive disclosure
- Immediate feedback
- Error recovery
- Value density (100x value per scroll/click)
- Reduced friction (auto-upload, smart defaults)
- Aha moment in <3 clicks
- Apple Design: Clean, minimal, spacious, purposeful
Performance
- Server Components for data fetching (reduced client bundle)
- Streaming with Suspense boundaries
- Real-time updates via SSE (no polling overhead)
- Optimistic UI updates
- Lazy loading for heavy components
- Client-Side Caching: Instant tab switching with cached data (no API calls)
- Server-Side Caching: Redis cache with 24h TTL for fast subsequent loads
- Smart Fetching: Only calls LLM when data doesn't exist (80%+ cost reduction)
- Render Optimization: `useCallback` for all fetch functions to prevent unnecessary re-renders
- Cache Invalidation: Manual regeneration bypasses all caches for fresh data
Accessibility
- Semantic HTML structure
- Proper ARIA labels where needed
- Keyboard navigation support
- Focus management