# glasses memory

Non-obvious learnings, Even Realities SDK quirks, transport gotchas, deferred decisions. Append-only.

## Even Realities G2 — SDK facts (read from hub.evenrealities.com/docs, 2026-06-12)

### Architecture (the big one)
- **App code runs ON THE PHONE, not the glasses.** The app is a normal web app (HTML/CSS/JS/TS, Vite/React/plain JS) running inside a WebView (Chromium on Android, WKWebView on iOS) hosted by the Even Realities phone app (a Flutter container).
- **Glasses = remote display + input only.** "No app logic runs on them." They render UI containers and emit input events (press/swipe) over BLE 5.2.
- SDK bridge: `bridge.callEvenApp(method, params)` (web→glasses) and `window._listenEvenAppMessage(...)` / `bridge.onEvenHubEvent(cb)` (glasses→web).
- **Consequence for us: networking is just normal `fetch()` from the phone WebView to omidlab.net.** We do NOT deal with raw BLE/MTU at all — that was a wrong assumption in ROLE.md. The phone has full HTTP. Low-MTU concerns are irrelevant; the only "compactness" concern is the *display* (tiny screen), not transport.

### Display (576×288 per eye, monochrome)
- Canvas 576×288 px per eye, origin top-left. 4-bit greyscale = 16 levels of green. Bright green = white; black pixels = transparent.
- **Containers, absolute-positioned by pixel. NO CSS/flexbox.** Max 4 image + 8 other containers per page.
- **TextContainer**: plain text, left+top aligned only. Wraps at container width, `\n` for line breaks. Firmware-based scrolling on overflow. Full-screen text holds ~400–500 chars. Limit 1000 chars startup / 2000 via textContainerUpgrade.
- **ListContainer**: native scrollable list, **up to 20 items, 64 chars/item**. Firmware handles scroll highlight. Updating a list = full page rebuild. ← perfect for the agent roster view.
- **ImageContainer**: greyscale ≤144×288. Can't send during createStartUpPageContainer (use placeholder + update).
- Single embedded LVGL font, no size/style/alignment control. Chars outside the font set are silently dropped (design guidelines have a supported-glyph table). No background colors, no animations, no programmatic scroll position.

### Input (one capture target per page!)
- `bridge.onEvenHubEvent(cb)` — single callback for ALL events. Route by `event.textEvent` / `listEvent` / `sysEvent` then switch on `eventType`.
- Gestures: `CLICK_EVENT` (press), `DOUBLE_CLICK_EVENT` (double press), `SCROLL_TOP_EVENT` (swipe up), `SCROLL_BOTTOM_EVENT` (swipe down). Temple touchpad or R1 ring.
- **Only ONE container per page captures events** (`isEventCapture: 1`). Design around a single active target per screen.
- **No text input field on glasses.** Input is gestures only → text entry must come from voice (mic) or canned/preset messages. See audio below.

### Audio / voice
- `bridge.audioControl(true)` → 16kHz signed-16-bit-LE mono PCM arrives via `audioEvent` in the onEvenHubEvent callback. No on-device transcription — WE pipe PCM to a STT service. serve.py already has `/api/transcribe` (Whisper) — candidate for voice→text send-back. No audio OUTPUT (no speaker).

### Other device APIs
- `bridge.getDeviceInfo()` / `onDeviceStatusChanged()`: model, serial, battery, wearing status, charging.
- `bridge.getUserInfo()`: user id, name, avatar, country.
- `bridge.imuControl(isOpen, freq)`: head orientation/motion.
- `bridge.setLocalStorage()` / `getLocalStorage()`: key-value persistence. localStorage/IndexedDB/OPFS persist across suspend/kill. **No push notifications on glasses.**

### Page lifecycle / programming model
- Multi-page app = stack of pages. 4 core calls: `createStartUpPageContainer` (once at startup), `rebuildPageContainer` (full redraw, flickers — for layout changes), `textContainerUpgrade` (in-place text, flicker-free — for counters/status), `shutDownPageContainer(mode)`.
- **Must call `shutDownPageContainer(1)` from root page** (mode 1 = system exit dialog). Silent exit (mode 0) from root = QA rejection.
- `textContainerUpgrade`/`updateImageRawData` require exact containerID+containerName match or they silently no-op.
- Always `await waitForEvenAppBridge()` before any SDK call — calls before bridge ready silently no-op.

### Networking constraints (IMPORTANT for our backend)
- `fetch()`, XMLHttpRequest, WebSockets all supported from the WebView.
- **Two gates on every outbound request:** (1) destination origin must be in `app.json` `permissions` → `network` whitelist (full HTTPS origin, NO wildcards); (2) server must return correct CORS headers. **Whitelist does NOT bypass CORS.**
- → **serve.py currently sets NO CORS headers** (only `Accept-Ranges` in its `after_request`, serve.py:28). We MUST add `Access-Control-Allow-Origin` for the app to talk to omidlab.net. Coordinate with website_builder. This is the #1 concrete backend dependency.
- HTTPS required (omidlab.net via cloudflared is already https — good).

### Background / lifecycle gotchas
- **No reliable background execution / polling.** When phone backgrounds the Even app, WebView suspends (always on Android under memory pressure; iOS holds in-memory state longer). Network requests stall. WebSockets typically drop on Android.
- Treat Android resume as a cold restart: persist to localStorage, rebuild on relaunch, reconnect WS on relaunch. → **polling model must be foreground-only**; no expectation of background updates. Beta QA literally locks the phone 5 min to test suspend/resume survival.

### Build / sideload / dev loop
- Project = standard Vite. Only Even-specific file is `app.json` (manifest: package_id reverse-DNS, edition "202601", min_sdk_version, entrypoint, permissions[], supported_languages[]).
- Dev loop: `npm run dev` (Vite :5173) → `evenhub qr --url "http://<lan-ip>:5173"` → scan QR in Even Hub tab of phone app → glasses render live with Vite HMR over WebSocket. **Phone must be on same LAN as dev machine.** Backgrounding kills the dev WebSocket.
- `evenhub-simulator http://localhost:5173` for hardware-free iteration (but no real latency/input feel).
- Package: `evenhub pack app.json dist -o myapp.ehpk`. Submit .ehpk via dev portal. Personal/private builds allowed; multiple personal apps side-by-side. ~10MB max package. Developer account required to publish; no paid model yet.
- **Cameron sideloads/tests; I can't run on the device.** npm packages: `@evenrealities/even_hub_sdk`, `@evenrealities/evenhub-simulator`, `@evenrealities/evenhub-cli`.

### Useful external refs
- Community notes: github.com/nickustinov/even-g2-notes/blob/main/G2.md
- Community toolkit: github.com/fabioglimb/even-toolkit
- Design guidelines (glyph table): Figma (linked from docs/build/design-guidelines)

## Verified SDK API (read from node_modules @evenrealities/even_hub_sdk v0.0.10, 2026-06-12)

Installed the package on the lab server (`agents/glasses/app/`, node v20) and read `dist/index.d.ts` — this is the REAL surface, not docs guesses:

- **Bridge** (`await waitForEvenAppBridge()`): `createStartUpPageContainer(c) -> StartUpPageCreateResult`, `rebuildPageContainer(c) -> bool` (full-page swap, for changing pages/layout), `textContainerUpgrade(c) -> bool` (flicker-free in-place text), `updateImageRawData`, `audioControl(bool)`, `imuControl(open,freq)`, `shutDownPageContainer(exitMode)`, `getUserInfo`, `getDeviceInfo`, `getLocalStorage/setLocalStorage`, `onEvenHubEvent(cb)->unsub`, `onDeviceStatusChanged`, `onLaunchSource`, `callEvenApp(method,params)`.
- **Containers** (all `new XProperty({...})`, fields camelCase): `ListContainerProperty` {xPosition,yPosition,width,height,borderWidth,paddingLength,containerID,containerName,isEventCapture, **itemContainer: ListItemContainerProperty**}. `ListItemContainerProperty` {itemCount, itemWidth, isItemSelectBorderEn, **itemName: string[]**} ← the list rows. `TextContainerProperty` {... content, isEventCapture}. `ImageContainerProperty`.
- **Page wrappers**: `CreateStartUpPageContainer`/`RebuildPageContainer` both take {containerTotalNum (1-12), listObject:[], textObject:[] (max 8), imageObject:[] (max 4)}. So startup CAN be a list directly — build the real first page in createStartUp, don't startup-then-immediately-rebuild on the happy path.
- **`textContainerUpgrade`** uses `TextContainerUpgrade` {containerID, containerName, content, contentOffset, contentLength} — must exactly match the live container's id+name or it silently no-ops.
- **Events** `bridge.onEvenHubEvent((ev)=>...)`: ev has optional `.listEvent` / `.textEvent` / `.sysEvent` / `.audioEvent`. `List_ItemEvent` = {containerID, containerName, **currentSelectItemName, currentSelectItemIndex**, eventType}. `Text_ItemEvent` = {containerID, containerName, eventType}. Route by which field is present + switch on eventType.
- **`OsEventTypeList`** enum: CLICK_EVENT=0, SCROLL_TOP_EVENT=1 (swipe up), SCROLL_BOTTOM_EVENT=2 (swipe down), DOUBLE_CLICK_EVENT=3, FOREGROUND_ENTER_EVENT=4, FOREGROUND_EXIT_EVENT=5, ABNORMAL_EXIT_EVENT=6, SYSTEM_EXIT_EVENT=7, IMU_DATA_REPORT=8. (FOREGROUND_ENTER/EXIT via sysEvent → use to pause/resume polling later.)
- **Validate without the device**: `npm run build` (vite) on the lab server catches import/syntax errors fast. node+npm live at /data2/cameron/miniconda3/envs/uva/bin (node v20).

## Mac dev-loop access (2026-06-12)
- `ssh mac <cmd>` runs on Cameron's Mac (lands in /Users/cameronsmith). node/npm are homebrew (/opt/homebrew/bin, node v25) — NOT on the non-interactive PATH; wrap as `ssh mac 'zsh -lc "..."'` or call the absolute path. Always `timeout N ssh mac ...`.
- Mount `/home/cameronsmith/mnt/mac/` = sshfs of mac:~/Projects/robotics_testing/ (LIVE). **agents_stuff is NOT under that tree** — its mac-side path is elsewhere (ask Cameron; couldn't auto-locate via find/mdfind/history 2026-06-12).
- Dev loop now: edit on lab → commit+push → on mac `git pull` → if `npm run dev` running, Vite HMR re-renders on glasses live (no re-scan). Re-scan only needed after the dev server restarts.

## Mac dev environment — set up 2026-06-12 (Cameron had NOT cloned agents_stuff)
- Mac has NO GitHub access in non-interactive shells: no SSH key (`ssh -T git@github.com` → publickey denied) and no HTTPS keychain cred (clone → `-25308` / "could not read Username"). So can't `git clone`/`git pull` on the Mac headlessly.
- **Workaround = GitHub-free sync.** App copied lab→mac over the existing ssh pipe:
  `tar czf - --exclude=node_modules --exclude=dist -C app . | ssh mac 'tar xzf - -C ~/Projects/para-fleet-glasses'`
  Mac copy lives at **`~/Projects/para-fleet-glasses`** (a plain dir, NOT a git repo). Re-run that tar-pipe to push new edits, then Vite HMR re-renders on the glasses (keep Even app foreground; no re-scan needed).
- Installed on Mac: app deps (`npm install`) + `evenhub` CLI 0.1.13 global (`npm i -g @evenrealities/evenhub-cli`, at /opt/homebrew/bin/evenhub). node v25 homebrew.
- **Run the dev loop from lab:** `ssh mac` →
  - start server detached: `cd ~/Projects/para-fleet-glasses && nohup npm run dev > /tmp/glasses-dev.log 2>&1 &` (Vite binds 0.0.0.0:5173; log at /tmp/glasses-dev.log).
  - Mac LAN IP for the phone: **10.110.20.110** (also tailscale 100.66.139.38). en0.
  - QR onto Cameron's screen: `evenhub qr --url "http://10.110.20.110:5173" --external --scale 8` (`--external` opens the QR image in Preview; Cameron scans from screen).
- **Validate backend before Cameron scans:** `curl -s "http://localhost:5173/api/agents/list?pw=fabregas"` on the Mac exercises the Vite proxy → omidlab.net. Confirmed working 2026-06-12 (roster JSON + panes return). Some agents show status "unknown" (no/empty status.md).
- TODO long-term: add a GitHub SSH key to the Mac (needs Cameron to paste pubkey into GitHub settings) so the Mac can `git pull` directly instead of the tar-pipe.

## BREAKTHROUGH: simulator automation harness — test without glasses (2026-06-12)
The `evenhub-simulator` (`npm i -g @evenrealities/evenhub-simulator`, macOS native app) has an HTTP automation API that lets ME drive + inspect the app, no glasses, no Cameron:
- Launch: `nohup evenhub-simulator http://localhost:5173 --automation-port 9898 &` (loads the dev app, injects a MOCK bridge → app renders).
- `GET  /api/ping` → pong
- `GET  /api/screenshot/glasses` → 576×288 RGBA PNG of the rendered display. Pull to lab: `ssh mac 'curl -s http://127.0.0.1:9898/api/screenshot/glasses' > /tmp/x.png` then Read it.
- `GET  /api/console[?since_id=N]` → webview console.* + `[uncaught]`/`[unhandledrejection]`/`[fetch]`. (add console.log boot traces to see progress.)
- `DELETE /api/console` → clear buffer.
- `POST /api/input {"action":"up|down|click|double_click"}` → touchpad input.
This validated the whole app (roster→detail→send) end-to-end against the REAL fleet API (via the proxy). The sim mock bridge always fires ready, so it does NOT reproduce real-device bridge-handshake failures — but it proves the app logic is correct.

## CRITICAL: real input event routing (confirmed via sim console, 2026-06-12)
Events arrive in `onEvenHubEvent(ev)` split across TWO fields, not one:
- **swipe up/down → `ev.textEvent`** (container-scoped), eventType 1 (up) / 2 (down).
- **single press → `ev.sysEvent`**, eventType 0 (often OMITTED from JSON since 0 is default → treat `eventType===0 || undefined` as click).
- **double press → `ev.sysEvent`**, eventType 3.
The first multi-page build routed clicks via textEvent/listEvent and silently dropped every press — that's the "tap does nothing" bug. A single press can emit >1 sysEvent (press/release) → debounce ~300ms.

## Dev-loop gotcha: HMR doesn't rebind boot callbacks
`main()` registers the event handler once via `bridge.onEvenHubEvent()`. A Vite hot-swap replaces the module but the bridge still holds the OLD handler → event/boot changes don't take effect on hot-update. Fix: `if (import.meta.hot) import.meta.hot.decline()` at module top forces a full page reload on every edit. (Render-only changes were fine with plain HMR; this makes ALL edits reliable.)

## Real-glasses blocker (open, 2026-06-12)
Glasses stuck on "prototype loading" → app never called createStartUpPageContainer → either (a) phone couldn't load the dev URL (Wi-Fi AP/client isolation — network is 10.110.20.x, campus-style) or (b) Even app never injected the bridge so `waitForEvenAppBridge()` hangs. Mac side ruled out: firewall OFF, dev server returns 200 on the LAN IP 10.110.20.110:5173. Mac tailscale tailnet has NO phone (only lab boxes). No cloudflared/ngrok installed. NEXT: bisect via phone browser hitting the dev URL; if isolation, tunnel (brew install cloudflared → quick tunnel, set vite server.allowedHosts:true + hmr host).

## Architecture v2: HUB + server-side renderer + browser preview (2026-06-12)
Pivoted from a glasses-only app to a general HUB (Cameron's vision: modules for Agents, Robots, WHOOP, Spotify, Notes — like a wearable dashboard).
- **Server-side renderer** `agents/glasses/glasses_renderer.py` runs in tmux window `agents:glasses_feed` (uva-env python). Captures each agent's tmux pane + status, writes atomic JSON feed files into `reports/glasses_app/feed/` (served static via /browse). **ALL glasses formatting lives in this file — edit + restart the tmux window → live on glasses next poll (~2s). No app rebuild, no re-scan.** Knobs: WIDTH, SCROLLBACK, PERIOD.
- **Thin client** (`app/src/`): `core.js` (env-agnostic logic: pages + nav, renders plain-text "screens", handles semantic actions up/down/press/double) + `even.js` (glasses bridge display+input adapter) + `dom.js` (browser-preview adapter: draws same text into a 576×288 green "glasses screen", arrow keys + buttons) + `api.js` (feed fetch + send) + `main.js` (env detect: race waitForEvenAppBridge vs 2.5s timeout → Even on glasses, DOM in a browser).
- **One URL, two targets:** https://omidlab.net/browse/para/.agents/reports/glasses_app/index.html — glasses render via bridge; a plain browser renders the DOM preview. So Cameron previews in any browser (no Mac sim needed).
- **HUB model:** home = module launcher (MODULES array in core.js). `agents` module = roster → raw tmux mirror (scrollable, follows tail) → canned send. `robots/whoop/spotify/notes` = placeholders (each just needs a feed source + open handler).
- Deploy: `cd app && npm run build && rsync -a --delete --exclude=feed dist/ /data/cameron/para/.agents/reports/glasses_app/` (exclude feed so the renderer's live files survive). Client changes need redeploy + Cameron reload; renderer changes are live.
- Mac fully out of the runtime loop now (was only needed for the sim). Lab box has NO headless browser (no chromium/playwright) → I verify content by reading feed files + build; Cameron does visual via browser preview.

## Architecture v3: data-driven / server-driven views (2026-06-12)
The client is now a GENERIC renderer — no hardcoded pages. It fetches a view feed and renders it; new modules/screens are pure server-side feed changes (live, no re-scan). This was the last structural re-scan.
- **View feed schema** (`./feed/<name>.json`): `{mode:"list", title, items:[{label, do:ACTION}]}` or `{mode:"text", lines:[...], onPress:ACTION}`. ACTION = `{go:"<feed>"}` (navigate, pushes nav stack) or `{post:"<url>", body, then:"back"|{go}}` (hit live /api). Gestures: up/down = cursor/scroll, press = activate, double = back (exits at root).
- Client modules: `core.js` (generic renderer + nav stack), `api.js` (getFeed + post), `even.js`/`dom.js` (unchanged display+input adapters), `main.js` (env detect).
- **Renderer** `glasses_renderer.py` emits all feeds every 2s: `home.json` (launcher, from MODULES list), `agents.json` (window list), `agent_<key>.json` (mirror text, onPress→send), `send_<key>.json` (canned-msg post actions), `module_<id>.json` (placeholders). Agents list = every `agents`-session tmux window in index order, dedup'd, status from status.md.
- **To add a module:** append to MODULES in glasses_renderer.py + write its `module_<id>.json` (any mode). Live on next poll. WHOOP/Spotify/Notes/Robots are placeholders awaiting data sources.
- WHOOP data source: asked life_manager (2026-06-12) for how to read the WHOOP API (recovery/sleep/strain/steps/calories/workout). Pending its reply.

## Speech interface (planned, 2026-06-12) — see outbox brainstorm
Glasses have a 4-mic array (16kHz PCM via bridge.audioControl); NO glasses speaker, but the app runs in the phone WebView so audio OUT goes to phone/headphones. Building blocks exist: serve.py `/api/transcribe` (Whisper STT) + `/api/tts`. Plan: add a client `record` action type (tap-start/tap-stop, no hold gesture available) → PCM → /api/transcribe → post to agent /send or a router. Phase 1: dictate into focused agent. Phase 2: global "Speak" hub entry. Phase 3: glasses-assistant LLM that routes commands + TTS replies.

## Voice dictation (2026-06-12) — shipped Phase 1
Send menu has a "speak (voice)" item: tap-to-record / tap-to-stop -> /api/transcribe (faster_whisper 'small', expects multipart field "audio", returns {text}) -> confirm screen (transcript + Send/Clear). Implemented as a `record` action type ({record:{to:url}}) in the generic client. Audio capture abstracted per display adapter: even.js streams glasses mic PCM (16kHz s16le mono) via bridge.audioControl + audioEvent.audioPcm (arrives as number[]/Uint8Array/base64 → normalized), wraps as WAV; dom.js uses getUserMedia+MediaRecorder (webm). WAV-in-.webm-tempfile decodes fine in faster_whisper. Verified end-to-end on BOTH paths via headless chromium (fake mic) and the evenhub-simulator (audioPcm confirmed arriving). Next: Phase 2 = context-aware assistant/router for "glasses ..." commands; wake-word needs an on-device keyword spotter (no SDK hook for native "even" wake word — only raw audioControl). TTS available at /api/tts for headphone replies.

## Production install path (.ehpk) — 2026-06-12
Two deployment modes share ONE hosted backend (omidlab.net feeds + /api):
- **Prototyping:** QR dev sideload of https://omidlab.net/browse/para/.agents/reports/glasses_app/index.html?mode=glasses (ephemeral, live HMR-ish; client changes need re-scan).
- **Production:** packaged `.ehpk`, installed via dev portal → persistent app on the glasses home.
Packaging requirements learned:
- entrypoint MUST be a bundled local file (no remote-URL entrypoint) → packaged app runs from a LOCAL origin → fetches to omidlab.net are cross-origin → needs CORS. Added CORS to serve.py (after_request `*` + OPTIONS 204 preflight; via website_builder restart 2026-06-12). Client uses absolute https://omidlab.net URLs (works same-origin in dev too).
- app.json permissions: each needs name+desc; valid names {g2-microphone, phone-microphone, album, location, network, camera}. Ours: network (whitelist https://omidlab.net) + g2-microphone (voice).
- Pack: `cd app && npx @evenrealities/evenhub-cli pack app.json dist -o /data/cameron/para/.agents/reports/glasses_pkg/para-hub.ehpk`. Downloadable at omidlab.net/browse/para/.agents/reports/glasses_pkg/para-hub.ehpk.
- **Key payoff of data-driven design:** server feed changes (formatting, WHOOP, new modules) are live on BOTH dev AND the installed prod app — NO reinstall. Only client-code changes (new action types/gestures) need a re-pack + re-install. Bump app.json version each re-pack.
- Verified cross-origin works: served dist from a foreign origin (localhost:8181) headless → fetched omidlab.net feeds 200 → hub rendered. Caveat: private builds die after 5-min phone lock (relaunch by tapping app; instant re-fetch).

## Publish workflow (2026-06-13, Cameron's standing request)
On EVERY new client version: `npm run build` -> rsync dist to reports/glasses_app -> `evenhub pack` to reports/glasses_pkg/para-hub.ehpk -> commit/push. THEN copy the .ehpk to the Mac's `~/Downloads/para-hub.ehpk` so Cameron never has to navigate the download link. ALWAYS check the Mac is reachable first with a short timeout (`timeout 8 ssh -o ConnectTimeout=5 mac 'echo MAC_UP'`) and SKIP the copy if it's not up — do NOT hang on a closed Mac. Use the cat-pipe: `cat <ehpk> | ssh mac 'cat > ~/Downloads/para-hub.ehpk'`. Keep the filename exactly `para-hub.ehpk` (browser downloads created hash-named / "para-hub (1).ehpk" duplicates that confused the beta upload — overwrite the single canonical name).

## Dashboard module (2026-06-16)
Hub tile at ROW 2 ("Dashboard", `{go:dashboard}`). It's a `list_view` whose TITLE is the clock+date ("9:42am - Tue Jun 16") and each ROW is one life domain you swipe through and click to expand (Cameron's requested "swipe down + click to expand" model — a list, not a text card):
- **NOW** — current task + live elapsed ("(1:17)"/"(8m)"). do = `{record:{to:/api/glasses/now/set, after:stay, autosend:true}}` (tap-to-speak). serve.py `api_glasses_now_set` writes `now_state.json {task, started_at(iso,tz-aware)}`, appends prev task to `now_timeline.md`, AND sends single-line `new now task, time is <h:mm am/pm>: <task>` to the life_manager tmux window. Renderer `_now_task()` computes elapsed from started_at. (STEPS panel was dropped — WHOOP can't give steps.)
- **NEXT** — next event from `events.json` ([{time:"HH:MM",title}], 24h, today-only). life_manager maintains it. do=`{go:dash_cal}`.
- **TODO** — `_todo_summary()` parses life_manager's `/data/cameron/life/checklist.md` (`- [ ]`/`- [x]`) -> "3/7  <next undone>". do=`{go:dash_todos}` (full checklist text).
- **CAL** — intake from `/data/cameron/life/food/<YYYY-MM-DD>.md` ("Running total"/"Target" regex) + burn from WHOOP kcal. do=`{go:dash_food}`.
- **BODY** — `_whoop_brief()` (recovery%, hours_asleep, kcal_out; cached 60s) from whoop_lib. do=`{go:module_whoop}` (existing WHOOP detail; now written unconditionally inside the dashboard branch since module_whoop isn't its own MODULES tile).
All sources degrade to "--" when not logged yet. NO app reinstall needed (pure feeds). gitignore: now_state.json, now_timeline.md, events.json. State files live under agents/glasses/ (serve.py writes, renderer reads). life_manager dispatched 2026-06-16 to maintain events.json + log now-tasks + keep checklist current.

## Migration to omid-fleet (2026-06-22)
The fleet now runs on the **omid-fleet** VPS (not the lab box). Glasses-relevant changes:
- **serve.py runs on port 8094** (not 8090), launched as `./.venv/bin/python serve.py --port 8094` from `/data/cameron/para/.agents/reports/`. cloudflared routes omidlab.net -> localhost:8094.
- **Renderer Python**: use `/data/cameron/para/.agents/reports/.venv/bin/python` (has requests + spotify_lib/whoop_lib deps). The old `/data2/cameron/miniconda3/envs/uva` path does NOT exist here.
- **GOTCHA — renderer daemon must be started separately.** The migration post-setup (`agents_stuff/migration/03_vps_post_setup.sh` line ~98) creates the interactive **glasses** agent window but NOT the renderer daemon. The renderer (`glasses_renderer.py` infinite loop that regenerates all feed files) needs its own window. Symptom when missing: feeds frozen/stale (glasses show old data), but serve.py/cloudflared still serve the static files so it looks half-alive. Fix: `tmux new-window -t agents -n glasses_feed -c /data/cameron/agents_stuff/agents/glasses` then run the venv python on glasses_renderer.py. NOT yet auto-started on boot — make it persistent (add to post-setup or a systemd unit) so a reboot doesn't silently kill the feeds.
- FEED dir + SESSION unchanged: feeds at `/data/cameron/para/.agents/reports/glasses_app/feed`, mirrors tmux session `agents`.

## Persian tutor tile (2026-06-23)
Added a `Persian` module to the glasses hub mirroring omidlab.net/persian.
- Tap-to-speak → `POST /api/glasses/persian/ask` (serve.py) proxies to the persian
  app on :5001 (`/persian/api/ask`), keeps rolling history+turns in
  `agents/glasses/persian_state.json`.
- `persian_view()` shows transliteration + English + notes. The G2 display strips
  non-ASCII (NONASCII regex), so it CANNOT render Persian script — the translit is
  the read-along. Persian script only travels in the `tts` field (audio).
- Audio auto-plays via the existing tts path: `/api/tts` now detects Arabic-script
  (U+0600–U+06FF) text and serves the persian app's already-cached ElevenLabs mp3
  from `/data/cameron/life/projects/persian/static/audio/` (content-hash on the
  exact `"In Persian, you say: <script>"` string the app hashes → cache hit, no 2nd
  ElevenLabs call). Fallback `_persian_tts_generate()` reads the persian .env creds.
- All server-side: NO app re-pack/reinstall needed.
- NOTE: English Chat TTS (Piper) is BROKEN on omid-fleet — serve.py calls
  `python -m piper` but `python` isn't on PATH, piper isn't in the venv, and the
  voice model `/data/cameron/.piper_voices/en_US-ryan-high.onnx` is missing. Separate
  pre-existing migration gap; Persian is unaffected (it bypasses Piper).
