# SESSION_NOTES.md

V4 work since fork from `origin/main` (commit `2158bd9`, 2026-04-27). Read [`AGENTS.md`](./AGENTS.md) first for repo conventions.

---

## Major features added in V4

### Unified Chat (`/unified-chat`, also `/`)
A new landing page that combines chat and image generation in **one conversation thread**. Users describe what they want — text, image, or a mix — and the page picks the right path per message based on selected mode + any reference image.

- **Files:** `frontend_v3/src/views/UnifiedChat.vue`, `frontend_v3/src/services/quickTilesService.js`, router entry in `frontend_v3/src/router/index.js`. Backend: `backend/src/modules/quick-tiles/*` and `backend/src/admin/modules/admin-quick-tiles/*`.
- **Quick tiles** = capability cards in the hero state ("צ'אט / תמונה / לוגו / איור / ..."). Stored in DB, manageable from the admin panel. Seedable from `quick-tiles.config.json`.
- **Image proxy:** `/image/proxy` endpoint with SSRF-safe host whitelist (used to bypass CORS when an image returned by a generation provider is reused as a reference for img2img).
- **Aspect ratio + samples:** plumbed through `ImageDto`/`ImageToImageDto` → `replica-image-provider`. Multi-sample is implemented by **firing N parallel single-image API calls** instead of relying on model native batch (which often returns one collage).
- **Brand-level model picker:** clean two-tier UI — top level shows brand chips (ChatGPT, Claude, Gemini, ...), inline chevron expands sub-models (Sonnet/Haiku/Opus, etc.). Currently picks "primary" = first model in group (placeholder); will be sourced from a real `isPrimary` DB field once admin support is added.
- **Thinking-message rotator:** while waiting for a response we show a sparkle icon + a rotating Hebrew/English status line ("חושב..." → "מבין..." → "מנסח תשובה..." → ...) cycling every ~1.6s. Separate copy track for image generation.
- **Char-by-char typing animation:** ported 1:1 from the classic `/chat` page (RAF-based, `receivedContent` → `displayContent`, 1 char per frame). The reactivity bug worth knowing: `aiMsg` was the raw object pushed to `messages`; mutations on the raw object don't trigger re-renders. Fix: use `messages[messages.length - 1]` (the reactive proxy) when calling streaming functions.
- **Conversation history + deep links:** `/unified-chat/:conversationId` route, history drawer in the page header listing past unified conversations (filter on `chat-history.type === 'unified'`), one-click load. Each generated image is also persisted into the chat-history doc via a new `POST /chat/append-image-message` endpoint so reloading a conversation shows the images inline with the text.
- **Per-message model override:** picker selection is honored *per request* without mutating `subscription.aiModel`. Backend reads `createChatDto.chat.model` first (new optional field on `ChatDto`), falls back to subscription. Classic chat unchanged. The `chat:request_received` debug event surfaces both the requested and subscription model + a `modelSource` tag.
- **Multi-reference images:** unified `references[]` array (kinds: upload / chat / paste). Same data model serves vision input (chat mode) and img2img references (image mode). Capped per model via the existing `maxReadImages` field. Ctrl+V paste of an image attaches it without auto-flipping to image mode.
- **Image-intent hint:** debounced regex on the textarea spots Hebrew/English image-request phrases ("צייר לי…", "draw a cat") and pops a soft "switch to image mode?" pill — never auto-flips, dismissible, auto-hides after 6s.
- **Document attachments (PDF / DOCX / TXT / MD):** 📎 button in the input bar. Extraction runs entirely in the browser (`pdfjs-dist` + `mammoth`, lazy-loaded — same libs Documents already ships). Text gets prepended to the prompt with clear delimiters before the user's question. **Zero backend touch.**
- **Image Mix Mode:** 🎲 toggle in the image-model picker. When on, one image is generated from each of several different models in parallel (one per model id, samples=1). Auto-picks 3 distinct brands on first toggle (groups by parent `aimodelgroup` name, not by id prefix — was a bug). Manual multi-select supported up to 4. Each tile shows the source model's name as a badge.
- **Reasoning UI scaffolding:** the bubble already supports a collapsible "💭 Reasoning" `<details>` block fed from `msg.receivedReasoning`. **Disabled at the source for now** because the legacy `@google/generative-ai` 0.21.0 SDK doesn't expose `parts[].thought`, so the thinking text leaks into content. Re-enable once the SDK upgrade to `@google/genai` lands.
- **Inline cost badge (debug users only):** approximate ≈$ shown under each assistant text bubble + as a chip on image bubbles. Driven by `cost-tracker` module.
- **Isolated branch:** `feature/unified-chat` (worktree at `/home/user/bina_plus_workspace/branches/unified-chat`) — note this branch was based at an earlier commit and is **out of date** vs the trunk; refresh before opening a PR upstream.

### Documents (`/docs`) — AI doc writer + canvas editor
A user-gated workspace for generating long-form documents with AI and editing them inline. Split layout: documents list (left), AI chat panel (middle), TipTap rich-text canvas (right).

- **Files:** `backend/src/modules/documents/{module,service,controller,model,dto,interface}.ts`. Frontend: `frontend_v3/src/views/Documents.vue` (~600 lines), `frontend_v3/src/services/documentsService.js`. Route `/docs` in `router/index.js` with `meta.requiresDocs`. Sidebar entry in `components/sidebar.vue` (filtered by `docsOnly`).
- **Backend module:** `DocumentsModule` registered in `app.module.ts` after `DebugEventsModule`. Imports `ClaudeModule`, `AiModelsModule`, `forwardRef(UsersModule)` (for RolesGuard's UserService dep). Mongoose collection `userdocuments`. Class is named `BinaDocument` (not `UserDocument`) to avoid Mongoose's built-in `Document` name collision; field is `modelId` (not `model`) to avoid Mongoose's `doc.model` reserved property.
- **Endpoints (all gated to `DOCS_USERS_EMAILS` whitelist via `assertAccess`, throws `NotFoundException` 404 if not whitelisted — invisible feature pattern):**
  - `GET /api/documents` — list current user's documents (no chatHistory)
  - `GET /api/documents/:id` — full doc with chat history
  - `POST /api/documents` — create empty doc
  - `PATCH /api/documents/:id` — update title/content/model
  - `DELETE /api/documents/:id` — delete
  - `POST /api/documents/:id/generate` — **streaming** AI doc generation; body `{prompt, model?}`. Streams plain markdown chunks; ends with sentinel `<<<DOCS_END>>>{json}` (or `<<<DOCS_ERROR>>>{json}` on failure).
- **AI flow:** Uses `ClaudeTextProvider.generateTextStream()` directly (NOT the chat-history pipeline — no SafeMode, no transactions, no 175ms-per-word delay; document generation should be as fast as the model can produce). System prompt enforces "output only the document content in markdown, no preamble/postamble". For follow-up edits, current doc content is included in the user turn so the model rewrites the whole doc (whole-doc-rewrite pattern, MVP — patch/diff mode is a future improvement).
- **Default model:** `claude-sonnet` (resolves to `claude-sonnet-4-5-20250929` via `aimodelgroups`). Selectable via dropdown — only Claude brand models are listed in MVP. `resolveProviderKey()` falls back to `claude-sonnet-4-5-20250929` if the chosen model isn't found or `canGenerateText: false`.
- **Frontend canvas:** TipTap (`@tiptap/vue-3` 3.22 + `@tiptap/starter-kit` + `text-align` + `placeholder`). Streamed markdown is converted to HTML on-the-fly via `marked` (already a dep) and pushed into the editor with `editor.commands.setContent(html, false)`. After streaming completes, `getHTML()` becomes the canonical content saved to DB. Toolbar: B/I, H1/H2/H3, lists, RTL/LTR alignment.
- **Word export:** `html-docx-js` on the frontend — bundles the editor HTML with a CSS style block + RTL direction into a `.docx` blob the user downloads. No backend involved.
- **Auto-save:** PATCH on every editor change (debounced 800ms). Title saves on blur. The streaming endpoint also persists final content + chatHistory in one shot at the end.
- **Whitelist:** `DOCS_USERS_EMAILS` env (comma-separated). Empty/unset = feature invisible to everyone. Flag flows to frontend as `user.isDocsUser` from `app-config`. Currently set to `info@odma.co.il,israel25@enativ.com`.
- **Router guard:** new `requiresDocs` meta + branch in `router.beforeEach` redirects non-flagged users to `/`. Sidebar entry filtered by `docsOnly` flag.
- **Branch isolation:** developed in `main/` (so it's live on `idev.binaplus.co.il/docs` immediately for the whitelisted users). Will be extracted to a clean `feature/docs` worktree off `origin/main` only when the feature is mature for upstream PR — same retroactive-extraction pattern used for `feature/unified-chat` and `feature/debug-panel`.
- **Visual language:** matches Unified Chat — uses the V4 design tokens (`--color1: #3478FF`, `--color2: #1B2559`, `--bg1/bg2`, `--light_blue`, `--light_gray`, font `Ploni`). Mirrors `UnifiedChat.vue`'s shimmer-gradient thinking text (`linear-gradient(90deg, #3478FF → #10b981 → #3478FF)`), bouncing dots animation, pill chips, sparkle icon as brand mark, light_blue avatar for assistant, color1 for user, soft shadows `rgba(27, 37, 89, 0.05–0.18)`. Keeps a 3-column layout (documents list / chat panel / canvas) since it suits the document UX better than a single column. Hero state with 4 suggestion cards (formal letter / summary / quote / plan) when no chat history yet.
- **Drive integration: placeholders only (UI in topbar).** Two ghost chips "פתח מ-Drive" + "שמור ל-Drive" with "בקרוב" badges. Click → `alert()` for now. The full Google Drive plumbing is already wired in the project (`services/googleDriveService.js`, backend `users.controller.ts` `connect-google-drive` / `get-google-drive` / `disconnect-google-drive`, GIS+GAPI script loader, OAuth code flow, Drive Picker, auto-refreshing access token via `getValidGoogleDriveAccessToken`) — when we phase-2 it we just need: (a) `downloadFile(fileId)` on the Drive service, (b) Google Doc → HTML / `.docx` → HTML conversion, (c) save-back endpoint. ~3-5 hours.
- **Hebrew-first:** All UI strings use `isRTL ? 'עברית' : 'English'` ternary, default lang is `HB` so Hebrew RTL is what users actually see. Suggestion cards / thinking rotator / placeholders / button labels — all separate HE/EN copy tracks.

### Audio Studio (`/audio`) — multi-model transcription with word-level playback
A user-gated workspace for transcribing audio (uploaded files or live recordings) with multiple selectable providers and a "karaoke" playback experience that highlights each word as the audio plays. Long-running, async job model so the user can switch away while a transcript is being generated.

- **Files:** `backend/src/modules/audio/{module,service,controller,model,dto,interface}.ts`, providers under `backend/src/modules/audio/providers/{whisper,gemini}-transcription.service.ts`. Frontend: `frontend_v3/src/views/AudioStudio.vue` (~880 lines), `frontend_v3/src/services/audioService.js`. Route `/audio` in `router/index.js` with `meta.requiresTranscription`. Sidebar entry in `components/sidebar.vue` filtered by `transcriptionOnly`. Lang key `sidebar.audio_studio` in `langs.js`.
- **Backend module:** `AudioModule` registered in `app.module.ts` after `DocumentsModule`. Imports `HttpModule` (Whisper REST) and a `forwardRef(UsersModule)` (for `RolesGuard` → UserService DI). Mongoose collection `audioitems`. Class is `AudioItem` (not `Audio`) and field is `modelId` — same `Document` / reserved-property avoidance pattern Documents uses.
- **Endpoints (all gated to `TRANSCRIPTION_USERS_EMAILS` via `assertAccess`, throws `NotFoundException` 404 if not whitelisted — invisible feature pattern):**
  - `GET /api/audio/models` — list available transcription models (catalog for the picker UI)
  - `GET /api/audio/items` — list current user's transcripts (lean, no segments/words/text)
  - `GET /api/audio/items/:id` — full item with text + segments + words
  - `GET /api/audio/items/:id/file` — streams the raw audio bytes for playback. Bypasses `TransformInterceptor` (sends `Content-Type: audio/*` directly so axios can resolve it as a Blob).
  - `POST /api/audio/items/:id` (PATCH) — update title / edited text (autosave on edit)
  - `DELETE /api/audio/items/:id` — deletes item + removes the audio file from disk
  - `POST /api/audio/transcribe` (multipart) — creates the item in `status='pending'`, persists the audio blob to disk, kicks off background transcription (fire-and-forget), returns the new item synchronously.
- **Async job lifecycle:** `pending` → `processing` → `completed` / `failed`. The frontend polls `getItem(id)` every 2s while in pending/processing. The service implements `OnModuleInit` and sweeps orphaned items (left in pending/processing from a previous backend run) to `failed` on startup, so the UI never spins forever after a hot-reload or crash.
- **Providers (pluggable, multi-model):** Two `ITranscriptionProvider` impls in MVP — picked via `modelId` in the request body:
  - `WhisperTranscriptionService` — calls **OpenAI Whisper** directly (via axios + FormData, not through the existing shared `ChatGptAudioProvider` so we can request `response_format=verbose_json` + `timestamp_granularities=['word','segment']` for word-level timing). Uses the same `CHAT_GPT_TOKEN` env. Max 25MB per file.
  - `GeminiTranscriptionService` — uses **`@google/genai`** with audio inline data + a JSON-schema prompt that asks for `{text, language, segments}`. No word-level timestamps (Gemini doesn't expose them). Max 20MB. Same `GOOGLE_GEMINI_API_KEY` env as the existing Live API path.
  - Adding a third provider is `class FooTranscriptionService implements ITranscriptionProvider` + a case in `resolveProvider()` + one entry in `MODEL_CATALOG`. No other code changes.
- **Audio storage:** uploaded files persist on local disk under `/tmp/binaplus-audio/<userId>/<itemId>.<ext>`. Survives backend hot-reload but not machine reboot — acceptable for MVP. GCS migration is a future improvement (we already have the bucket configured). The audio path is per-user namespaced; deletes remove both the DB row and the file.
- **Upload limit ↑500MB + ffmpeg pre-compression:** the original 25MB ceiling (Whisper's hard `/audio/transcriptions` body limit) is bypassed by piping every upload through `ffmpeg -ac 1 -ar 16000 -b:a 32k -f mp3` (stdin→stdout, no temp files) **before** forwarding to the provider. Compresses ~48× off WAV / ~5–10× off webm; a 25MB payload now holds ~17 hours of audio. The Multer `FileInterceptor` cap is set to 500MB and matches `MAX_UPLOAD_SIZE_MB` in `audio.service.ts`. The compressed buffer goes to the provider; the **original** file stays on disk untouched so playback in the studio plays the real upload, not the compressed copy.
- **MediaRecorder mime fix (recording path):** `AudioRecorder.vue` labels its output `audio/wav` but the actual bytes are MediaRecorder's native codec (webm/opus on Chrome/Firefox, mp4/aac on Safari). The fake `wav` label made the playback `<audio>` element refuse the file. The fix lives only in `AudioStudio.vue`'s `onRecorded` handler — re-wraps the blob with `MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'` before upload — `AudioRecorder.vue` itself is untouched (still used by classic chat).
- **Usage tracking (`CostTracker.trackTranscription`):** every successful transcription emits a structured `audio_usage` log line (`{userId, email, modelId, durationSeconds, audioFileSizeBytes, subscriptionPlan, approxCostUsd, at}`) for **all** users, plus an `audio:cost` debug event for debug users. Pricing in `cost-tracker/pricing.config.json` under the new `audio` section ($0.006/min for Whisper, $0.00015/min for Gemini Flash). `costUsd` is persisted on each `AudioItem` at completion so the monthly aggregate is a single Mongo query.
- **Pilot budget guardrails (env-driven, AudioService.assertCreditsAvailable):**
  - `TRANSCRIPTION_MAX_MINUTES_PER_CALL` (default **120**) — pre-flight via `ffprobe` on the saved file. Files over the cap are rejected with `400` + Hebrew message ("קובץ ארוך מהמותר — מקסימום X דקות") *before* any provider call. Disk file is unlinked on rejection.
  - `TRANSCRIPTION_MONTHLY_USD_PER_USER` (default **$10**) — sum of all `completed` items in the current calendar month for the user × per-minute rate. When over, `startTranscription` throws `402 Payment Required` with errorInfo `{ code: 'AUDIO_MONTHLY_CAP_EXCEEDED' }`. The frontend recognises this code and shows the server's Hebrew message verbatim.
  - **New endpoint `GET /api/audio/usage`** returns `{monthlySpentUsd, monthlyCapUsd, remainingUsd, maxMinutesPerCall, periodStart, periodEnd}`. The studio fetches it on mount + after every `pending → completed/failed` transition, and renders a "$0.02 / $10" chip in the toolbar (neutral / amber at 80% / red over cap, `dir="ltr"` to keep digit order under RTL).
  - **Plan-tier limits later:** `assertCreditsAvailable` is the single integration point — read `user.subscription.plan` and pick a different cap. Pricing model already supports per-model rates.
- **Mobile layout** (`@media (max-width: 768px)`): single-column grid; items pane becomes a slide-in drawer (from the trailing edge — `inset-inline-start` + `transform: translateX(-100%)`; RTL flips the slide direction automatically). New **hamburger button** in the toolbar opens it; tap on backdrop or the X closes it. Selecting an item auto-closes the drawer so the user sees the transcript. Toolbar drops button labels (icons only), the model description line, and the player's playback-rate selector to fit a phone. Suggestion cards in the hero collapse from a 3-up grid to single column. At ≤420px the budget chip wraps to its own row to avoid horizontal scroll.
- **Frontend studio (`AudioStudio.vue`):** two-column layout (items list + active panel). Active panel modes when item is `completed`:
  - **Word-sync (default when words exist):** clickable `<span>` per word, highlighted via `data-start/end` against `audio.currentTime`. Click → seek. RTL-aware progress bar (clicking the right half seeks back, not forward).
  - **Plain (segments or full):** when only segments exist (Gemini path) or user prefers a clean view — segment rows with `mm:ss` timestamps, click-to-seek.
  - **Edit:** plain `<textarea>` with debounced (800ms) autosave via PATCH. Words/segments aren't touched on edit — they remain tied to `originalText` so the user can compare to the raw Whisper output later.
- **Authenticated audio playback:** axios `responseType:'blob'` to `/audio/items/:id/file` with the Bearer header, then `URL.createObjectURL` for the `<audio>` element. Plain `<audio src=...>` won't work because the browser doesn't send auth headers for media requests. **Two non-obvious traps here, both bit us:**
  1. `api.js` exposes `get(url, params, config)` — the config slot is the **third** argument, not the second. Passing `{ responseType: 'blob' }` as the second slot silently makes it a query-string param. Axios then falls back to JSON-parsing the response, which UTF-8-mangles every non-ASCII byte to `0xEF 0xBF 0xBD` and the file balloons by ~60%; the browser sees `text/plain` and refuses to demux ("PipelineStatus::DEMUXER_ERROR_COULD_NOT_OPEN"). Fix: `api.get(url, null, { responseType: 'blob' })`.
  2. The global `TransformInterceptor` (main.ts) wraps every controller return value in a `{success,...,data}` envelope. `@Res({ passthrough: true })` doesn't escape it — `StreamableFile` returns serialise as `"[object Object]"`. Reliable bypass: `@Res()` non-passthrough **plus** `await new Promise` on the stream's `finish` event so the controller doesn't return while pipe is mid-write (otherwise the interceptor's `map()` races against the body and corrupts it). See `audio.controller.ts#streamFile` for the working pattern.
- **Player play/pause icon — inline SVG, not Font Awesome:** Font Awesome's SVG-with-JS replacement runs once on mount and turns `<i class="fa-play">` into an `<svg>`. When Vue then tries to toggle that element via `v-if`/`v-else` to swap to a pause icon, the `<i>` node FA already removed is gone — Vue throws `Cannot read properties of null (reading 'insertBefore')` and the icon never updates. Same pitfall hit the toolbar's record button (mic ↔ stop). Both use **inline `<svg>` with two literal path strings** chosen by `v-if`/`v-else` instead, sidestepping FA entirely.
- **Recording reuse:** the `🎤 הקלטה` button mounts the existing `AudioRecorder.vue` from the classic chat — emits a Blob on stop, which we feed straight into the transcription flow. Zero new recording code.
- **Real progress UI (replaces cycling copy):** the processing state shows a **progress bar with a real percentage** + stage label + elapsed time. Backed by two new `AudioItem` fields: `stage` (`'' | 'compressing' | 'transcribing'`) and `probedDurationSeconds` (ffprobe at upload). Stage transitions happen inside `processInBackground` — compressing is the 0–20% range (asymptotic, usually < 5 s), transcribing is the 20–95% range scaled by `elapsed / (audioSec × 0.08)` so the bar moves at a realistic rate for the audio length. Never hits 100% until status actually flips to `completed`. The label uses the existing shimmer-gradient text technique. The previous cycling copy ("מתמלל… → מזהה דובר… → …") is gone — it lied about progress on long files.
- **Export:** copy-to-clipboard + plain `.txt` download in MVP. `.srt`/`.docx` are tracked for phase 2 (we already ship `html-docx-js` for Documents).
- **Whitelist:** `TRANSCRIPTION_USERS_EMAILS` env (comma-separated). Empty/unset = feature invisible to everyone. Flag flows to frontend as `user.isTranscriptionUser` from `app-config`. Currently set to `info@odma.co.il,israel25@enativ.com` (narrower than DOCS/BETA on purpose — provider costs are per-minute).
- **Router guard:** new `requiresTranscription` meta + branch in `router.beforeEach` redirects non-flagged users to `/`. Sidebar entry filtered by the new `transcriptionOnly` flag.
- **Unified Chat integration (planned, owned by Papa):** dropping an audio file onto Unified Chat will prompt "send to transcription?" and POST it to `/api/audio/transcribe` — same endpoint the studio uses. No backend changes needed on our side.
- **Branch isolation:** developed in `main/` (so it's live on `idev.binaplus.co.il/audio` immediately for the whitelisted users). Will be extracted to a clean `feature/audio` worktree off `origin/main` when mature for upstream PR — same retroactive-extraction pattern used for `feature/unified-chat`, `feature/debug-panel`, and Documents.
- **What we explicitly did NOT touch:** `backend/src/modules/ai-voice/` (Gemini Live API for realtime voice — a different feature surface), `chat-history#transcribe-audio` (the classic chat's existing OpenAI-Whisper endpoint used by chat + WhatsApp + file handler — both stay as-is). Phase 2 may unify these.

#### Integration points (for other features that want to use Audio)

All endpoints live under `/api/audio/*`, are guarded by `JwtAuthGuard` + `RolesGuard` (`USER_ROLE.user | admin | super_admin`), and additionally gated by the `TRANSCRIPTION_USERS_EMAILS` whitelist. Non-whitelisted users get **404** (invisible-feature pattern), not 403.

**Endpoints:**

- **`POST /api/audio/transcribe`** — multipart/form-data. **Asynchronous** — returns immediately with a `pending` item; the caller polls `GET /api/audio/items/:id` until `status === 'completed'` (or `'failed'`). There is no webhook callback.
  - Body (multipart form fields):
    - `audio` — binary blob (required)
    - `model` — `whisper-1` (default, word-level timestamps) | `gemini-flash` (no word timing). Catalog at `GET /api/audio/models`.
    - `title` — optional display title (defaults to the upload filename)
    - `language` — optional ISO-639-1 hint (e.g. `he`, `en`). When omitted both providers auto-detect.
  - Response (201): the freshly created `AudioItem` document in `status: 'pending'`. Persist `_id`; poll it for the result.
  - Errors:
    - `400` — empty file, unknown model, file over upload limit (500MB), or audio duration > `TRANSCRIPTION_MAX_MINUTES_PER_CALL` (default 120 min — pre-flighted via ffprobe before any provider call).
    - `402` — `errorInfo.code: 'AUDIO_MONTHLY_CAP_EXCEEDED'` with Hebrew `message`. The caller should surface the message verbatim.
    - `404` — user not on the transcription whitelist.

- **`GET /api/audio/items/:id`** — full item (text + segments + words + language + duration + costUsd). Poll this after `/transcribe` until `status` changes. `errorMessage` is populated on `failed`.
- **`GET /api/audio/items`** — list current user's transcripts (lean projection, no text/segments/words). Sorted newest first.
- **`GET /api/audio/items/:id/file`** — streams the original audio bytes (bypasses `TransformInterceptor`). Use this to play back the audio for an item — `Authorization: Bearer <jwt>` header is required, so a plain `<audio src="…">` won't work; fetch as a blob and `URL.createObjectURL` it.
- **`PATCH /api/audio/items/:id`** — body `{ title?, text? }`. Words/segments are NOT touched on edit (they remain tied to `originalText`).
- **`DELETE /api/audio/items/:id`** — removes the DB doc and the audio file from disk.
- **`GET /api/audio/models`** — catalog of available providers: `[{ id, provider, labelHe, labelEn, descriptionHe, descriptionEn, supportsWordTimestamps, maxFileSizeMB, isDefault }]`. Source of truth for the model picker UI; new providers added to `MODEL_CATALOG` in `audio.service.ts` show up automatically.
- **`GET /api/audio/usage`** — current month's pilot-budget state for the calling user: `{ monthlySpentUsd, monthlyCapUsd, remainingUsd, maxMinutesPerCall, periodStart, periodEnd }`. Cheap (one aggregate) — safe to refetch after each upload.

**Frontend routes:**

- **`/audio`** — main studio (empty hero if no item is selected).
- **`/audio/items/:id`** — deep link to a specific transcript. The studio mounts, auto-selects the item, and loads its audio for playback. Used by Unified Chat's "send to transcription" pill (owned by Papa) so a user lands directly on the new transcript.
- Query params: **none supported** today. `?preload=<url>` (load remote URL into the studio) and `?text=<text>` (TTS direction) are **not implemented** — design TBD.

**Limits:**

- Max upload size: **500MB** per file (Multer cap; matches `MAX_UPLOAD_SIZE_MB` constant in `audio.service.ts`).
- Max audio duration: **`TRANSCRIPTION_MAX_MINUTES_PER_CALL`** minutes per request (default **120**). Pre-flighted via `ffprobe` before any provider call.
- Supported formats: anything `ffmpeg` can decode (we pre-compress every upload to 32kbps mp3 mono 16kHz before forwarding to the provider). Verified happy paths: **mp3, wav, m4a, ogg, webm, flac, mp4 (audio track)**. Files saved on disk under `/tmp/binaplus-audio/<userId>/<itemId>.<ext>` — lost on machine reboot (GCS migration is phase 2).
- Per-user monthly USD cap: **`TRANSCRIPTION_MONTHLY_USD_PER_USER`** (default **$10**). Sum of `costUsd` from completed items in the current calendar month. Once exceeded, `/transcribe` returns 402 until next month.

**Providers used + env vars:**

- **OpenAI Whisper** (`whisper-1`) — via `CHAT_GPT_TOKEN` (already in env; NB: this is the project's name for the OpenAI key — not `OPENAI_API_KEY`).
- **Gemini 2.0 Flash audio input** (`gemini-flash`) — via `GOOGLE_GEMINI_API_KEY` (already in env).
- **New env vars introduced by this feature** (must be added to any new deployment env):
  - `TRANSCRIPTION_USERS_EMAILS` (required, comma-separated) — whitelist; empty/unset = feature invisible to everyone.
  - `TRANSCRIPTION_MAX_MINUTES_PER_CALL` (optional, default 120).
  - `TRANSCRIPTION_MONTHLY_USD_PER_USER` (optional, default 10).

**What it doesn't support (yet):**

- **Live mic streaming / realtime transcription.** Recording is captured-then-uploaded; for realtime voice see `ai-voice` module (different surface — Gemini Live).
- **Webhook callback on completion.** Caller must poll `GET /api/audio/items/:id`.
- **Speaker diarization.** Whisper doesn't expose it; Gemini's text mode can be coaxed but timings would be unreliable. The clean paths are: (a) wire `thomasmol/whisper-diarization` on Replicate (we have `REPLICA_API_KEY`, requires async polling on Replicate's side — adds ~30s per call), (b) route diarization-capable requests through the user's planned custom endpoint (WhisperX / pyannote.audio behind a small FastAPI). Both fit cleanly into the existing `MODEL_CATALOG` + `ITranscriptionProvider` abstraction — the response shape just needs an extra `speakers: [{id, segments: [{start,end}]}]` field on `IAudioTranscriptionResult`. Decision pending; no UI yet.
- **Translation task** (Whisper's `task=translate`) — not exposed.
- **Server-side chunking** for files whose compressed size still exceeds the provider's hard limit — not needed in practice (~17h of audio fits in 25MB at 32kbps mp3).
- **Per-conversation history / threading.** Each transcription is a standalone item; there's no concept of "conversation about a transcript".
- **TTS (text → speech).** Opposite direction; out of scope.

If you change any endpoint shape, query param, env var, or limit listed above, update this block — it's the contract other features rely on.

#### ivrit-ai provider (Hebrew Whisper on RunPod)

Third provider in the catalog (model id `ivrit-ai`) — Papa's RunPod-served `ivrit-ai/whisper-large-v3-turbo-ct2` worker. Recommended for Hebrew audio; segment-level only (no word timestamps, no diarization). The provider is **dormant** until the three IVRIT_* env vars are set; `listModels()` and `resolveProvider()` both gate on `IvritAiTranscriptionService.isConfigured()` so the picker stays clean on deployments without the endpoint.

**Wiring overview** (no HTTP plumbing on our side — Papa's `IvritAiService` does the RunPod handshake):

- `IvritAiModule` (in `shared/third-party/services/ivrit-ai/`, `@Global()`) is registered in `app.module.ts`. The injected `IvritAiService` handles POST `/v2/{endpoint}/run`, polls `/status/{id}` until COMPLETED, and defensively normalises the worker's three output shape variants.
- Thin adapter [`audio/providers/ivrit-ai-transcription.service.ts`](backend/src/modules/audio/providers/ivrit-ai-transcription.service.ts) implements `ITranscriptionProvider` and maps `IvritAiTranscriptionResult` → `IAudioTranscriptionResult` (text + segments + language + durationSeconds; `words` and `speakers` are `[]`).
- Catalog entry added in `audio.service.ts#listModels()` only when `this.ivritAi.isConfigured()` returns true. The entry sets `supportsWordTimestamps: false` so the studio falls back to the segment-level view automatically (the word-sync mode toggle hides itself for items without words).

**Env vars** (already in `.env.stg`):

```
IVRIT_API_KEY=rpa_…
IVRIT_ENDPOINT_ID=hwm9jatqayuz3i
IVRIT_MODEL=ivrit-ai/whisper-large-v3-turbo-ct2
```

**What was retired:** the earlier `CustomTranscriptionService` (generic HTTP wrapper expecting `CUSTOM_TRANSCRIPTION_URL`) was removed — ivrit-ai supersedes it and follows the standard Bina pattern (provider service under `shared/third-party/services/`) so upstream PRs migrate cleanly. If we ever want a separate WhisperX/diarization endpoint, we add a dedicated provider then; the speaker fields on `AudioItem` + `IAudioTranscriptionResult` stay (forward-compat — costs nothing).

**Five landmines from first integration (write down or step on them again):**

1. **The model validator must read `listModels()`, not `MODEL_CATALOG`.** ivrit-ai isn't in the static catalog — it's pushed in at runtime conditionally on `isConfigured()`. `audio.service.ts#startTranscription` originally checked `MODEL_CATALOG.find(...)` directly and threw "Unknown model: ivrit-ai" even though the picker showed it. Anywhere we validate a model id, go through `this.listModels()`.
2. **The RunPod endpoint ID in the handoff env may be wrong.** The original `IVRIT_ENDPOINT_ID=hwm9jatqayuz3i` returned 404 on `/run` AND `/health`. The correct active one was `0rkvd7ej6ojgbj` (workersMax=5) — discoverable via the RunPod GraphQL `myself { endpoints { id name workersMax } }` query, which is the right diagnostic step before assuming our code is broken.
3. **The worker contract puts `model` at top-level of `input`, not inside `transcribe_args`.** Papa's first cut sent `{ input: { transcribe_args: { blob, language, task } } }` and the worker rejected with `"Model not provided"`. The correct shape is `{ input: { model: '<id>', transcribe_args: { blob, language, task } } }`. Fixed in `IvritAiService.submitJob`.
4. **RunPod's `/run` body cap is 10 MiB and the audio is base64-encoded in the JSON body.** A 9.5MB compressed mp3 → ~12.7MB base64 → 400 with `"bad request: body: exceeded max body size of 10MiB"`. Our default ffmpeg target was 32kbps; lowered to **16kbps mono 16kHz** (constant `COMPRESSED_BITRATE_KBPS` in `audio.service.ts`) so a 1-hour file is ~7MB compressed / ~9.6MB base64 — fits with margin. Whisper is fully accurate down to 16kbps for speech; the original-quality file on disk is untouched, so playback isn't affected. If a file ever exceeds that ceiling we'll need to chunk (server-side ffmpeg split + stitch the segments back together with offset timestamps).
5. **The worker output is a typed event stream, not a flat segments array.** First cut of `normalizeSegments` treated `output[0].result` as `[{start, end, text}, ...]` and silently returned `[]` for every real upload — the studio showed "completed, 0 chars". The actual shape is `output[0].result = [{ type: 'progress', data: {…} }, { type: 'segments', data: [{ start, end, text, words: [{word, start, end, probability, speaker}] }, …] }, …]`. The fix walks the events, keeps only `type === 'segments'`, and flattens their `data` arrays. As a bonus the worker emits **word-level timestamps + per-word confidence** — surfaced through `IvritAiWord` and forwarded to the studio so word-sync (karaoke) playback works for ivrit-ai items too (catalog updated to `supportsWordTimestamps: true`).

### ivrit-ai provider (Hebrew Whisper on RunPod)

Pure injectable service that wraps the `ivrit-ai/whisper-large-v3-turbo-ct2` model served from a RunPod serverless endpoint. Built as a Bina-native third-party provider (matches the convention used by Claude / OpenAI / Replica / Stability) so it can be cherry-picked upstream without touching feature modules. **No HTTP endpoint, no controller** — it's a class that other modules inject and call directly.

**Files:**
- [`backend/src/shared/third-party/services/ivrit-ai/ivrit-ai.service.ts`](backend/src/shared/third-party/services/ivrit-ai/ivrit-ai.service.ts) — the service
- [`backend/src/shared/third-party/services/ivrit-ai/ivrit-ai.module.ts`](backend/src/shared/third-party/services/ivrit-ai/ivrit-ai.module.ts) — `@Global()` so any feature module can inject it
- [`backend/src/shared/third-party/services/ivrit-ai/ivrit-ai.interface.ts`](backend/src/shared/third-party/services/ivrit-ai/ivrit-ai.interface.ts) — `IvritAiTranscriptionResult`, `IvritAiSegment`, `IvritAiTranscribeOptions`
- Registered in `app.module.ts` next to `StabilityAiModule`.

**Public API:**

```ts
@Injectable() IvritAiService {
  isConfigured(): boolean;                      // true iff IVRIT_API_KEY + IVRIT_ENDPOINT_ID set
  modelId: string;                              // env IVRIT_MODEL or fallback
  transcribe(audioBuffer: Buffer, options?: {
    language?: string;                          // default 'he'
    task?: 'transcribe' | 'translate';          // default 'transcribe'
  }): Promise<{
    text: string;                               // joined segments
    segments: { start: number; end: number; text: string }[];
    language: string;
    durationSeconds?: number;
  }>;
}
```

**Behavior:**
- Submits to `POST https://api.runpod.ai/v2/{IVRIT_ENDPOINT_ID}/run` with `{ input: { transcribe_args: { blob: <base64>, language, task } } }` and `Authorization: Bearer {IVRIT_API_KEY}`.
- Polls `GET /v2/{IVRIT_ENDPOINT_ID}/status/{jobId}` every 3s, up to 10 min total. RunPod statuses handled: `COMPLETED` → return segments; `FAILED` / `CANCELLED` / `TIMED_OUT` → throw with the error body verbatim.
- Defensive segment normalization (`normalizeSegments`) handles three variants we've seen from RunPod workers: `output: [{result: [...]}]`, `output: {result: [...]}`, `output: [...]`. A small worker tweak won't break the integration.

**Env vars (added to `backend/.env.stg`):**

| Var | Required | Default | Notes |
|---|---|---|---|
| `IVRIT_API_KEY` | yes | — | RunPod API key (starts `rpa_`). Dormant until set. |
| `IVRIT_ENDPOINT_ID` | yes | — | RunPod endpoint id (e.g. `hwm9jatqayuz3i`). |
| `IVRIT_MODEL` | no | `ivrit-ai/whisper-large-v3-turbo-ct2` | Surfaced via `modelId` getter — used only as a display label by callers. |

**Integration path for AudioModule (Mr 2):**

The existing `CustomTranscriptionService` (which goes through `CUSTOM_TRANSCRIPTION_URL` as a generic HTTP plugin) should be replaced with a thin wrapper that injects `IvritAiService` directly. Sketch:

```ts
// backend/src/modules/audio/providers/ivrit-ai-transcription.service.ts
@Injectable()
export class IvritAiTranscriptionService implements ITranscriptionProvider {
  readonly modelId = 'ivrit-ai';
  constructor(private readonly ivrit: IvritAiService) {}
  isConfigured() { return this.ivrit.isConfigured(); }
  async transcribe(params): Promise<IAudioTranscriptionResult> {
    const result = await this.ivrit.transcribe(params.buffer, { language: params.language });
    return { text: result.text, language: result.language, durationSeconds: result.durationSeconds, segments: result.segments };
  }
}
```

Then register it in `audio.service.ts` `resolveProvider()` and add it to `MODEL_CATALOG` with a Hebrew label. No `IvritAiModule` import needed (it's `@Global()`).

**Why a pure provider service (not a public HTTP endpoint):**
- Matches Bina's existing convention (`shared/third-party/services/<provider>/`) — clean upstream PR with no architectural surprises.
- No public surface means no controller, no DTO, no guard, no Swagger entry — fewer things to review.
- Audio is internal: only `AudioModule` needs it. If a second consumer appears later (e.g. realtime mic via `ai-voice`), they inject the same `@Global()` service.

### Cost Tracker (debug-only)
A floating session-cost line at the top of the Debug Panel + inline ≈$ badges under each assistant message. Approximate (provider list rates from `backend/src/modules/cost-tracker/pricing.config.json` — token counts for chat are estimated as `chars/4` because the legacy stream chunk shape doesn't carry `usage`; image costs are per-call from the pricing table). Hooked into `stream-response-handler` (chat end-of-stream), `generate-image.service` (both txt2img + img2img success paths). New event types `chat:cost` / `image:cost`. Pure additive — debug users only, all other users see nothing.

### Debug Panel — wall time + font size + solo filter
Each event row shows HH:MM:SS in addition to the relative offset (full ISO on hover). A− / A+ buttons in the header bump the panel font 10–18px (persisted to localStorage). Category chips support **solo mode** (alt-click, right-click, or the ⊙ icon on hover) — isolates one category; an "↺ All" reset button appears when solo is active.

### Image generation prompt visibility (debug)
New event `image:final_prompt` emitted from Flux + OpenAI image providers right before the API call. Shows: raw user prompt, style preset, `promptEnhancerEnabled`, negative prompt (per-model from filter-settings), and the fully assembled `finalPrompt`. Also fixed a latent bug where the OpenAI image service was looking up `image_negative_prompt` under a hardcoded `openai/gpt-image-1` key — now resolved from `payload.model`, so negative prompts configured for gpt-image-2 etc. actually apply.

### Live Debug Panel
A floating panel that streams behind-the-scenes events for chat and image generation — useful for diagnosing latency, the SafeMode filter pipeline, and provider responses without leaving the app.

- **Files:** `backend/src/modules/debug-events/{module,service}.ts`, `frontend_v3/src/services/debugService.js`, `frontend_v3/src/components/common/DebugPanel.vue`, toggle button in `frontend_v3/src/App.vue`.
- **Backend:** `DebugEventsService` is global; emits typed events (`chat:request_received`, `chat:llm_dispatch`, `chat:llm_first_chunk`, `chat:filter_check_start`, `chat:filter_check_end`, `image:request_received`) via the existing socket gateway, scoped to whitelisted users only.
- **Frontend:** `debugService.js` collects events from HTTP (axios interceptor), sockets, and the dedicated `debug:event` channel. Categorizes (http / chat / image / socket / error). `DebugPanel.vue` renders a floating dark panel with category filters.
- **Whitelist:** `DEBUG_USERS_EMAILS` env (comma-separated). Empty/unset = feature disabled for everyone. The flag flows to the frontend as `user.isDebugUser` from `app-config`.
- **Important:** when reading `userData` (Mongoose document) and spreading it (`{ ...userData, isDebugUser }`), spread doesn't pick up the underlying schema fields. Call `.toObject()` first. See `app-config.service.ts`.
- **Isolated branch:** `feature/debug-panel`.

### Closed beta access
- `BETA_USERS_WHITELIST` env (comma-separated emails). Only those emails can log in.
- Google login disabled, signup link hidden in the UI.
- `assertBetaAccess` runs in 3 auth methods (login, social, register).
- Adding a tester = update env + restart backend.

**Currently allowed (live values in `backend/.env.stg`):**
```
BETA_USERS_WHITELIST=
  info@odma.co.il, israel25@enativ.com, g.hananel20@gmail.com,
  motirosen@idev.binaplus.co.il, davidrabi@idev.binaplus.co.il,
  eliana@bdev.com, bg@bdev.com, talh@bdev.com, itzik@bdev.com,
  akiva@bdev.com, bg@idev.com

DEBUG_USERS_EMAILS=
  info@odma.co.il, israel25@enativ.com
```
The `*@bdev.com` and `*@idev.com` accounts are synthetic test users created in the local DB (passwords were set by the developer; not real mailboxes).

### Streaming UX tuning
- **Backend `wordLimit` reduced 30 → 15.** First chat content appears at ~2.6s instead of ~5.5s after the SafeMode filter check.
- **175ms per-word delay kept (intentional).** It paces the filter check — reducing it makes filtering visibly stutter. Don't lower without discussion.
- **NPM `proxy_buffering off`** at the location level is required for SSE to flow chunked over HTTPS. See `DEPLOYMENT.md`.
- **Vue dev server** also disables proxy buffering for `/api` and `/socket.io` so local dev matches staging.

### Login redirect race fix
On successful login the user used to bounce back to `/login` until they refreshed. Cause: the post-login `router.push(openURL)` ran before `appConfigService.refreshAppConfig()` populated the store, so the route guard saw an empty store. Fix in `App.vue`'s `reload()` — `await reloadPage()` **before** the navigation.

### Reference image flow
- Click any chat-generated image → it becomes a reference for img2img.
- Backend proxy endpoint (`/image/proxy`) bypasses CORS for CDN-hosted images.
- Whitelist of trusted hosts in the proxy (`cdn.binaplus.co.il`, `replicate.delivery`, `pbxt.replicate.delivery`, `storage.googleapis.com`).

### SafeMode blocked rendering
Both classic and unified chat now render **full HTML response** for blocked content (server returns sanitized HTML with red coloring and the blocked reasons). `Try Again` button retries the same prompt.

### Theming — versioned palettes, swap by uncommenting one `@import`
Frontend brand colors live as CSS variables in `frontend_v3/src/css/_variables.scss` (`--color1`, `--color2`, `--light_blue`, `--bg2`, etc.). Alternative palettes are version-suffixed override files; only one is active at a time, chosen by which `@import` is uncommented in `general.scss`.

**Versions in the repo (2026-05-11 design experiment):**
| Name | File | Description |
|------|------|-------------|
| **v0** | (none — uses `_variables.scss` defaults) | Original blue/navy: `--color1 #3478FF` / `--color2 #1B2559`. Energetic, generic "tech blue". |
| **moti1** | `_theme-turquoise-moti1.scss` | Designer Moti's first turquoise: teal-600 primary / teal-900 text / teal-100 tints. Calm and clean but reads slightly "spa/healthcare". |
| **claude1** | `_theme-turquoise-claude1.scss` | Tighter, more "tech AI" variant: cyan-500 primary / cyan-800 text / cyan-100 tints. Keeps the turquoise identity, recovers energy. |

**Switching active version** — edit `frontend_v3/src/css/general.scss`, comment/uncomment exactly one `@import './_theme-*.scss';` line (or comment them all to fall back to v0). The Vue dev server hot-reloads CSS automatically.

**Adding a new variant** — copy one of the existing `_theme-*.scss` files, change the hex values, add an `@import` line for it in `general.scss`. Keep prior variants in the repo so a designer can A/B back to them.

**Brand-aware components** — UI surfaces that previously used hardcoded blue (e.g. the image-loading gradient in `UnifiedChat.vue`) have been migrated to `var(--color1)` / `var(--color2)` so they follow the active palette without per-theme overrides.

---

## Open items

### Designed, not built yet

1. **`isPrimary` flag on AI models.** The brand-level model picker currently uses "first model in group" as a placeholder. Add `isPrimary: boolean` to `ai-models.model.ts`, expose it via `admin-ai-models` panel, and make the unified chat read it.

2. **Auto router for chat / image.** Discussed approach:
   - **Chat:** `gpt-auto`, `claude-auto`, etc. — auto-tier within a brand (cheap → smart based on prompt). Heuristic-only first, no extra LLM call.
   - **Image:** `image-auto` — heuristic across all providers based on style preset + keyword scan ("צילום" → Flux, "לוגו" → Ideogram, "אנימה" → SD-Anime).
   - **Where it lives:** new `backend/src/modules/model-router/` module with rules in `model-router.config.json` (Phase 1) → DB + admin UI (Phase 2).
   - **Frontend:** sends `model: '<brand>-auto'` → backend resolves to concrete model id → returns the picked model in the response so frontend can display "Auto → GPT-4o-mini".

3. **Conversation history for unified chat.** The classic chat has full history sidebar; unified has none yet. Plan: reuse the same backend (chat-history) but extend the schema with a `mode` field (`chat` / `unified`) so the unified history is queryable separately.

4. **Per-conversation deep links** for unified chat (`/unified-chat/:conversationId` like the classic `/chat/:conversationId`).

### Known issues / tradeoffs

- **Stability.AI key (401)** — SD 3.5 / SD Ultra / SD Core don't work. All other image models (Flux variants via Replicate, etc.) are fine. User said: replace key when convenient.
- **`SERPAPI_KEY` not present** — Trip Planner web search was a v1 addition, not in main. Skip if asked.
- **Sidebar `subscription.plan` can be undefined** for accounts without a subscription. Already guarded with `?.` in `sidebar.vue`. Watch for similar patterns elsewhere.

### Pending decisions for upstream
The diff vs `origin/main` is **65+ commits / 79+ files**. Too big for one PR. Suggested split when the user gives the go-ahead:
- **PR1 (safest, all bugfixes)** — login race fix, image proxy endpoint, aspect_ratio in DTO, multi-image parallel, gpt-image-2 negative-prompt lookup fix, per-message model override, streaming `wordLimit` 30→15.
- **PR2 (additive, env-gated)** — Debug Panel + Cost Tracker + final-prompt visibility. No schema changes, env-driven via `DEBUG_USERS_EMAILS`.
- **PR3 (large feature)** — Unified Chat including quick-tiles backend + admin + history endpoints + multi-ref + mix mode + doc attach + image-intent hint. Self-contained route.
- **PR4 (optional)** — Documents module (Mr 2's work). Gated by `isDocsUser`.
- **PR5 (optional)** — Audio Studio (Mr 2's work). Gated by `isTranscriptionUser`. Self-contained `modules/audio/` + `views/AudioStudio.vue` + small additions to `app-config.service.ts` (3 lines), `router/index.js` (one route + one guard branch), `sidebar.vue` (one menu entry + one filter rule), `langs.js` (one i18n key). Trivial to revert.
- **Don't upstream** — closed beta whitelist (may be unwanted in main), turquoise theme files (designer test), `forpapa*.md` (agent-generated notes).

The `feature/debug-panel` and `feature/unified-chat` worktrees under `/home/user/bina_plus_workspace/branches/` exist but were created early and are **stale** — they don't include the later improvements. Refresh by re-applying file-level changes from `main` before opening a PR.

### Pending small fixes / docs

- Document the new debug events in `SYSTEM_DOCUMENTATION.md` § 6 (chat flow) and § 7 (image flow).
- The `BETA_USERS_WHITELIST` and `DEBUG_USERS_EMAILS` env vars aren't in `.env.stg.original` — when refreshing env from upstream, re-add them. Same goes for `DOCS_USERS_EMAILS` and `TRANSCRIPTION_USERS_EMAILS`.
- **Audio Studio storage:** files live under `/tmp/binaplus-audio/` — lost on machine reboot. Migrate to GCS in phase 2 (we already have `GOOGLE_CLOUD_STORAGE_BUCKET_NAME` configured). Also consider server-side ffmpeg chunking for files > 25MB so users aren't limited to short recordings.

---

## Conventions surfaced from this work

- **Each isolatable feature → its own worktree branch off `origin/main`.** Don't bundle. The user wants to be able to revert / cherry-pick individually.
- **Don't push to `origin` casually.** `v4` is the dev remote.
- **Streaming behavior is sensitive** — the SafeMode filter pipeline expects the 175ms-per-word delay. Reducing it makes filtering visibly stutter.
- **Vue 3 reactivity:** when adding objects to a reactive array and then mutating them, always re-read via `array[i]` to get the proxy. The originally-pushed reference is the raw target.
- **Mongoose docs and spread don't mix.** Use `.toObject()` before `{ ...doc, extra }`.
- **Hebrew-first UI.** RTL is the default. Use `this.isRTL` for any direction-dependent rendering.
