CODEX // oaoisme wiki

§ Forge — hypertrophy tracker

Connected — Telegram nudges + exercise alternatives

updated 2026-06-09

Forge's Connected feature has two independent halves that share no state: a Telegram forge plugin that pulls live status over the loopback, and a pure exercise-alternative recommender surfaced on each exercise page. Both lean on the same discipline the rest of Forge uses — pure logic in small modules, the DB only in db.js, the server thin.

Part B — the alternatives recommender

alternatives.js is a pure module (no DB, no clock, no I/O — the caller passes the catalog in), a sibling of engine.js/coach.js.

What "primary muscle" means. In catalog.js each exercise carries muscles: { key: weight } where 1.0 is a prime mover and ~0.3–0.8 an assist. A fixed >= 0.8 cut would misfire on a lift whose hardest contribution is 0.9 (e.g. bb_row → lats 0.9), leaving it with no primary. So primaryMuscles(ex) resolves primaries relative to the lift's own top weight: the muscle(s) at (or within a hair of) the maximum. That gives bb_bench → [chest], db_bulgarian → [quads] (its glutes:0.8 is an assist, excluded), and bb_row → [lats].

How a swap is chosen. alternativesFor(key, exercises, opts):

  1. Filters to candidates whose equip is in the allowed set (default barbell + dumbbell — the gym's kit).
  2. Keeps only candidates that share ≥1 primary muscle with the source. Assist-only overlap does not qualify — that keeps the list to true swaps, not "also touches the chest a bit." This is why bb_bench yields db_bench and db_incline_press (both chest:1.0) but not db_shoulder_press (no chest at all).
  3. Ranks deterministically: scoremin(srcWeight, candWeight) over shared primaries) descending, then more shared primaries, then total muscle overlap, then alphabetical by key — so the ordering is stable and unit-testable.

It accepts muscles as either a parsed object (pg returns JSONB pre-parsed; catalog.EXERCISES is objects too) or a JSON string, so it can't silently double-parse into an empty primary set.

Where it surfaces. The server's /exercise/:key route resolves the swaps, maps each shared-primary key to its display name, and renders a "Swap / alternatives" block on views/exercise.ejs — teal links inside the existing .muscletags/.mtag chips, each row a ≥44px touch target, no new colour identity. A JSON twin lives at GET /api/alternatives/:key for any external/editor use.

Part A — the Telegram forge plugin

The bot framework (/root/apps/telegram-bot) is hot-swappable: drop a dir under plugins/, it loads within ~1s. The router long-polls Telegram outbound only — no inbound port, UFW untouched. The forge plugin runs network_mode: host, so it reaches Forge on http://127.0.0.1:3600 (loopback) — the same pattern as the stats and rituals plugins.

The data contract: GET /api/status. A new read-only Forge endpoint that reuses the exact db reads + coach.js the web pages already use (no schema, no writes). It returns scalars/arrays only, so the plugin stays "dumb":

  • nextDay — the next rotation day + focus.
  • recoverycoach.recoveryWarnings() for the next day's target muscles (which are still cooked).
  • deloadcoach.deloadSignal() (stalled lifts + molten muscles + over-MRV + block length).
  • prs — best est-1RM per key lift, with its date.
  • weekly — sessions, working sets, tonnage, PR count, and the volume-audit headline.
  • lastTrainedDays — days since each program day was last trained (drives the stale nudge), from a new db.lastSessionPerDay() read helper.

The plugin's pure core. render_status, weekly_summary, nudge_for, and weekly_alert_for are all pure functions of the status dict (plus an injected "today"/"now"), so they unit-test without a clock or a network round-trip (pytest plugins/forge/tests).

  • /forge command → render_status: next day, recovery, deload, latest PRs, weekly headline, in a monospace block.
  • stale watcher (hourly, 12h cooldown): if the next rotation day hasn't been trained in ≥ STALE_DAYS (default 3), emits a "N days since you trained " Alert, keyed per (day, date).
  • weekly watcher (hourly): the framework has no cron primitive — only interval watchers, so the weekly summary self-gates on local weekday+hour (Monday 09:00) and keys its Alert by ISO week, so the key + cooldown guarantee exactly one fire per week.

What's live vs scaffolded (honesty)

  • Live + tested: /api/status, /api/alternatives/:key, the exercise-page swap list, and /forge rendering — the e2e suite hits the real endpoints, and pytest covers the renderers/decisions.
  • Scaffolded + unit-tested, not message-asserted: the scheduled nudge/weekly delivery. The watchers are real and the plugin registers in the router (visible in docker compose logs), but their firing as a Telegram message is not asserted end-to-end (no chat round-trip in CI). The decision functions (nudge_for, weekly_alert_for) are unit-tested directly instead.
  • Out of scope: server-initiated web push (VAPID) — nudges are Telegram-only; local notifications are handled by the in-app rest-timer cue.

Files

  • Forge: alternatives.js (+ alternatives.test.js), server.js (/api/status, /api/alternatives/:key, /exercise/:key passes alts), db.js (lastSessionPerDay()), views/exercise.ejs, public/forge.css (.altlink, .mtag.alt), e2e.mjs.
  • Bot: plugins/forge/{plugin.toml, main.py, requirements.txt, tests/test_render.py}, .env (FORGE_URL).