# Silver Code Walkthrough

A pragmatic tour of the Silver flow — what runs when, where to read, and where to change.
Focused on the questions Mads asked: scheduling, day counting, lesson selection, module
gates, group creation, and the AI agent.

---

## 1. The scheduler — "where does the bot decide it's time to send something?"

**File:** `lib/groupJobs.js` (yes — confusingly named; it owns BOTH Gold's daily
job feed AND Silver's 8/14/18 ticks).

**Function:** `startJobScheduler(sock, scriptDir, botDir, aiFn)` — line 258.

**Boot:** `index.js:808` calls `startJobScheduler(sock, SCRIPT_DIR, BOT_DIR, ai)`
once Baileys is connected. After that the scheduler is permanent until process exit.

**Heartbeat:** a `setInterval(..., 60_000)` block at `lib/groupJobs.js:335`. Every
60 seconds it:

1. Reads Zurich-time hour + minute via `getSwissTime()` (line 21).
2. Checks if the hour is `8`, `14`, or `18` — otherwise no-op.
3. For every `mentees/{slug}/silver.json` it finds, computes a deterministic
   per-slug "send minute" 0–59 via `slugToMinute(slug)` (line 36 — hash of slug),
   so users don't all get pinged at the same second.
4. Fires `tickSilverUser(scriptDir, slug, slot)` from `lib/silverCourse.js` on
   exactly the right minute, deduped per `${slug}|${date}|${slot}` so it can't
   double-fire if the interval drifts.

**TL;DR:** the scheduler is a polling loop in `lib/groupJobs.js:335-355`. There's
no cron daemon — the Node process IS the cron.

---

## 2. "Today's date" — how the bot knows what day it is

Two layers, both in **`lib/clock.js`**:

- `now()` — wraps `new Date()`. In tests we monkey-patch this to fast-forward time
  (see `Clock.advance()` in `scripts/dev-server.js`).
- `zurichParts(now())` — returns `{ date: "2026-05-06", hour, minute, ... }` in
  **Europe/Zurich** time. Used everywhere we need to render "today".

**Why a wrapper instead of `new Date()`?** So the dev harness can fake time. Tests
in `scripts/test-*.mjs` rely on this.

---

## 3. Day counting — "what day of the course is this user on?"

**File:** `lib/silverStore.js`, function `elapsedDayNumber(state)` (line 200ish).

```js
export function elapsedDayNumber(state) {
  const start = new Date(state.startedAt).getTime();
  const ms = now().getTime() - start;
  return Math.max(1, Math.floor(ms / 86_400_000) + 1);
}
```

So: `Day 1` is the day the user starts (`startedAt` is set at Hotmart approval
in `silverCourse.startSilverFromHotmart`). `Day 2` is 24h later, etc.

**Caveat:** this is a wall-clock day counter (always +1 every 24h). It's NOT
a calendar day counter — if a user starts Tuesday 23:00, they hit "Day 2" at
Wednesday 23:00, not Wednesday 00:00. Good enough for the cohort; if you want
calendar-day boundaries, change `Math.floor(ms / 86_400_000)` to a Zurich-date
diff.

---

## 4. Lesson selection — "which video does the bot send today?"

This is the part Mads specifically asked about. The answer is **not** "look up
day N in lessons.json" — it's slightly smarter.

**Two functions in `lib/silverStore.js`:**

### `nextLessonToSend(scriptDir, state)` — lines ~177-184

Used by the **08:00 morning send**. Walks `lessons[]` in catalog order, returns
the first one without `completedAt`. So:

- Day 1: user has no completions → returns L01.
- User confirms L01 with "Yes" → L01 gets `completedAt` set.
- Day 2: returns L02.
- User skips a day → next morning still returns L02 (the one not yet done).

**This is intentional** — the bot doesn't skip ahead because the user missed a
day. The user always finishes a lesson before getting the next.

### `nextCatchupLesson(scriptDir, state)` — lines ~190-200

Used after a "Yes" reply to find a CATCH-UP lesson the user can do *right now*.
Capped to `today = elapsedDayNumber(state)` so we never send tomorrow's lesson
early. The catch-up cap is `CATCHUP_CAP_PER_SESSION = 2` in
`lib/silverCourse.js:18` — at most 2 extra sends per "Yes".

### Where it's actually triggered

- Morning send: `lib/silverCourse.js` `morningSend()` → calls `nextLessonToSend` → `sendLesson(...)`.
- Catch-up: `lib/silverCourse.js` `onYes()` → calls `maybeCatchupSends()` → `nextCatchupLesson`.

### "Can I have 2 videos in 1 day?"

**Today: no, by schema.** `lessons.json` has one lesson per `day` field. The
helper `lessonByDay(scriptDir, day)` returns the **first** match.

**Smallest change to support it:** treat `day` as "earliest send day". Multiple
lessons can share the same day. The `nextLessonToSend` walk would still return
them one at a time, in catalog order. The user gets L08a in their morning send,
confirms it, and `maybeCatchupSends` immediately fires L08b (since L08b's `day`
is ≤ today and it's incomplete). The 2-per-session catch-up cap covers this.

**Bigger change:** add an explicit `bundle` or `pairWith` field. E.g.:
```jsonc
{ "id": "L08a", "day": 8, "title": "Part 1: ...", "bundleWith": "L08b" }
{ "id": "L08b", "day": 8, "title": "Part 2: ..." }
```
and `sendLesson()` walks the bundle. Clearer at the cost of a schema bump.

For first cohort I'd start with the lighter approach (multiple lessons on the
same `day`); if you want me to wire it, ~30 min.

---

## 5. Module gates — "Module 1 vs Module 2"

**Schema:** each lesson in `data/silver/lessons.json` has a `module: 1|2|3|4`
field. Currently:

| Module | Days |
|---|---|
| 1 | 1–7 |
| 2 | 8–14 |
| 3 | 15–21 |
| 4 | 22–30 |

**Detection function:** `lib/silverStore.js` `maybeMarkModule1Complete(scriptDir, state)`
(line ~363). It loads `lessons.json`, filters `module === 1`, and checks
**every** of those lessons has `completedAt` set on the user. If yes, it flips
`accessLevel` to `dossier_pending` and journey-logs `silver_module1_complete`.

It's called from two places in `lib/silverCourse.js`:
- `onYes()` — after the user confirms a lesson.
- `applyThinkificCompletion()` — removed (Thinkific gone).

So the flip happens *immediately* on the L07 "Yes" (or the last Module-1 lesson,
whichever it is per the catalog). The dashboard sees it next time it polls
`GET /admin/silver/pending-unlock`.

### "What if I want to require A+B+C tasks (not just video completion) to pass Module 1?"

Currently the gate is purely "all Module-1 lessons watched". If you want to add
task gates (e.g. "must have submitted CV v1 + 5 applications + LinkedIn
optimised"), three small steps:

1. **Mark which tasks gate the module.** Add `gatesModule: 1` to the relevant
   entries in `data/silver/tasks.json`:
   ```jsonc
   { "id": "T-W1-applications", "week": 1, "gatesModule": 1, "askPrompt": "..." }
   ```

2. **Update `maybeMarkModule1Complete`** to also verify those tasks have
   `state.tasks[T.id]?.status === 'claimed'`:
   ```js
   const requiredTasks = loadTaskRegistry(scriptDir).tasks.filter(t => t.gatesModule === 1);
   const tasksAllOk = requiredTasks.every(t => state.tasks[t.id]?.status === 'claimed');
   if (!allLessonsDone || !tasksAllOk) return false;
   ```

3. **Tell the user what's missing** — when the lessons are all done but a task is
   pending, send a "Module 1 lessons done — still need to confirm: {missing
   tasks}" copy via `messages.en.json`.

This is ~20 lines of code; happy to wire it on request.

### "What if Module 2 should also gate something?"

Add `maybeMarkModule2Complete(scriptDir, state)` next to `maybeMarkModule1Complete`,
filter `module === 2`, and decide what its completion does — mark a milestone, send
a graduation copy, whatever. Currently nothing happens at Module 2/3/4 boundaries
because the only programmatic gate in the spec is the Module 1 → coach-unlock one.

---

## 6. Group creation — "is this the same as the old code?"

**Almost.** Walk through both:

### Old pattern — `lib/report.js` `generateAndSendReport()` (line 159)

Used for Gold leads. Coach triggers the flow (e.g. types `REPORT MyClient` in
their DM). The bot:

1. Generates the client profile report PDF (`generateReportText` + `buildReportPDF`).
2. Creates an empty WhatsApp group: `sock.groupCreate('Ecobe | ' + leadName, [])`.
3. Generates an invite link: `sock.groupInviteCode(groupJid)`.
4. Sends the invite link to the **coach's DM** (the `jid` parameter).
5. Coach forwards/shares the link with the client. Both join.
6. `lib/groupHandler.js` `checkPresence()` (line 64) classifies participants
   against `json/numbers.json` — anyone in `numbers.json` = coach, anyone else =
   client. Phase flips from `waiting` to `form` once both join.

### New pattern — `lib/silverCourse.js` `startSilverFromHotmart()` (line ~290)

For Silver, the trigger is a Hotmart webhook — no coach in the loop yet. So:

1. Compute `slug = phoneToSlug(buyerPhone)` (e.g. `silver-41791234567`).
2. Create the group with the **buyer pre-added**: `createGroup('Ecobe Silver | ' + buyerName, [buyerJid])`.
   So the buyer auto-joins on creation — no invite-link relay required.
3. Generate `inviteLink` for audit/dashboard display.
4. `coachJid: null` — explicitly UNASSIGNED. Coach claims later from the admin
   dashboard (option C you confirmed in the planning thread).
5. Bot is in the group automatically because it created it.

### "I want it the same as the old code"

Two sub-options if you want to preserve the old pattern strictly:

- **A. Empty group + invite link to a default coach.** Pick a default staff
  number from `json/numbers.json`. Modify `startSilverFromHotmart` to:
  - Create empty group.
  - Send invite link to default coach DM.
  - Set `coachJid` to that coach.
  - Coach forwards link to client.
  This breaks "option C" (no auto-assignment) but matches Gold exactly.

- **B. Empty group + invite link to BOTH client and the unassigned-pool list.**
  Bot DMs the buyer with the invite link directly. Coach gets pinged via the
  admin dashboard digest (TBD). Hybrid.

The current path (B-ish — auto-add buyer, no coach until claim) is the simplest
and closest to "user gets WhatsApp within 5 min of purchase". Tell me if you
want me to switch to A.

### Coach claim — what happens when a coach clicks "claim" in the dashboard

Not yet implemented. The dashboard endpoint `POST /admin/silver/users/:slug/claim`
(if we add it) would:
1. Read the group's `groupJid` from `silver.json`.
2. Add the coach: `sock.groupParticipantsUpdate(groupJid, [coachJid], 'add')`
   (already wrapped as `addToGroup` in `lib/whatsappAdapter.js`).
3. Set `state.coachJid` + `state.coachName` + `state.coachAssignedAt`.
4. Optional: send a "👋 Your coach {name} has joined the group" message.

Let me know if you want this wired now — it's ~20 minutes.

---

## 7. The AI agent — why was it silent?

**Was:** `lib/silverAgent.js` `shouldStub()` returned `true` whenever
`OPENAI_KEY` was unset. With only `ANTHROPIC_API_KEY` in `.env`, every Q&A
fell back to canned stub copy from `stubAnswer()`.

**Now (fixed):** `shouldStub()` keys off `ANTHROPIC_API_KEY`. When it's set,
`liveAnswer()` calls Anthropic Messages API with `claude-haiku-4-5` by default.

### Where Q&A actually fires

`lib/silverCourse.js` `handleSilverMessage()` is the inbound router. It checks
several short-circuits in order:

```
1. Dossier collecting?         → captureDossierAnswer
2. Weekly check-in unanswered? → record reply (and continue)
3. Human escalation regex?     → escalate
4. Awaiting role reply?        → handleRoleReply (APPLIED/SKIP/INFO)
5. Status prompt active?       → handleStatusReply
6. "Yes" + awaiting lesson?    → onYes
7. "No" + awaiting lesson?     → lesson_no copy
8. "send again"?               → resend lesson
9. Anything else                → runQA → silverAgent.answerQuestion
```

**So: Q&A only fires for free text that doesn't match any earlier short-circuit.**
"Yes", "Applied", "send again" all bypass it.

### How retrieval works (no vector store)

Anthropic doesn't have a managed vector store. Instead `silverAgent.loadCorpus()`
reads all 30 transcripts + `faq.md` once per process (`_corpusCache`) and inlines
them into the system prompt. Prompt caching (`cache_control: { type: 'ephemeral' }`)
means after the first request the corpus reads as **cached input tokens** — much
cheaper. With ~10–20K tokens of corpus and Haiku, each Q&A costs <0.1¢.

### How to verify it's actually using Claude

After your `.env` has `ANTHROPIC_API_KEY=...`:

```bash
cd ecobot
node -e "
import('./lib/silverAgent.js').then(async m => {
  const r = await m.answerQuestion(process.cwd(), {
    state: { groupJid: 'test', slug: 'test' },
    lesson: { id: 'L01', day: 1, week: 1, module: 1, title: 'Career Direction' },
    pendingTask: null,
    userText: 'Walk me through the CAR method like Im 5'
  });
  console.log(r.text);
});
"
```

If you get a paragraph in Claude's voice (not the canned `CAR = *Context, Action,
Result*` stub), it's live.

### Tools the agent can call

- `sendTemplate(templateId)` — looks up `templateById()` in `lib/silverStore.js`,
  posts the URL to the user's group.
- `escalateToHuman(reason)` — sets `escalate=true`, fires
  `silverCourse.escalate()` after the message.
- `getUserProgress()` — declared but not yet wired (returns nothing). Easy add.
- `recordTaskProof(taskId, status, userReply, proofUrls, notes)` — writes into
  `state.tasks[taskId]` so weekly check-ins know what's been done.

---

## 8. The `silver.json` schema — what gets stored per user

**Source of truth:** `lib/silverStore.js` `defaultSilverState()` (line 17).
Every field is documented inline. Quick map:

| Group | Fields |
|---|---|
| Identity | `slug, leadName, locale, groupJid, clientPhone, coachJid, coachName, email` |
| Lifecycle | `startedAt, accessExpiresAt (start+90d), currentDay` |
| Hotmart | `hotmartTransactionId, hotmartProductId, hotmartOfferId, paymentStatus, pausedAt, pausedReason` |
| Coach gate | `accessLevel ('course_only'/'dossier_pending'/'full'), accessLevelChangedAt/By, module1CompletedAt, coachTriggeredAt/By, coachUnlockedAt/By` |
| Dossier | `dossier: { status, collectionTurns[], generatedAt, strategyPath, reviewedBy, reviewedAt }` |
| Token | `whatsappToken: { value, mintedAt, expiresAt, consumedAt }` |
| Course | `lessons[id]: { sentAt, lastSentAt, completedAt, source, nudgedAt[] }` |
| Tasks | `tasks[id]: { taskId, status, userReply, proofUrls[], notes, recordedAt }` |
| Tracker | `appliedRoles[], shownRoles[], awaitingRoleReplyFor, statusPromptsSentToday, statusPromptsResetDate` |
| Misc | `weeklyCheckins[], escalations[], qaHistory[], pendingTaskAsk, awaitingYesFor, pendingQuestionFor, lastInteractionAt, completedAt` |

**Backwards compatibility:** every read goes through `loadSilver()` which calls
`migrateSilver()`. Adding a new field is safe — old user files load with the new
field defaulted. We've already used this twice (thinkificEmail → email,
unlocked → full).

---

## 9. End-to-end checklist for tomorrow's E2E test

When you have all the env vars:

```bash
# ecobot/.env
ANTHROPIC_API_KEY=sk-ant-...
SILVER_MODEL=claude-haiku-4-5             # optional
HOTMART_HOTTOK=...                        # from Hotmart product settings
HOTMART_MEMBERS_AREA_URL=https://members.hotmart.com/...
ECOBE_COMMUNITY_URL=https://...
WHATSAPP_ORCHESTRATOR_TOKEN=...           # any random 32 bytes
JOBBOT_BASE=                              # leave empty for mock mode
```

```bash
cd ecobot
npm install                # if not already
SILVER_TEST_MODE=1 npm run dev
# → opens dev UI at http://localhost:7331/dev
```

In the dev UI:

1. Click **"Onboard test user"** → creates `silver-test` mentee.
2. Click **"Fire purchase.approved"** under Hotmart webhook → fake Hotmart event.
3. Click **"Reply: Yes"** seven times to walk L01..L07 → access flips to
   `dossier_pending`.
4. Hit the coach admin endpoints via curl with your `WHATSAPP_ORCHESTRATOR_TOKEN`:
   ```bash
   curl -H "Authorization: Bearer $WHATSAPP_ORCHESTRATOR_TOKEN" \
        http://localhost:7331/admin/silver/pending-unlock
   ```
   Should return your test user.
5. Trigger dossier:
   ```bash
   curl -X POST -H "Authorization: Bearer $WHATSAPP_ORCHESTRATOR_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"coachEmail":"you@example.com"}' \
        http://localhost:7331/admin/silver/users/silver-test/trigger-dossier
   ```
6. In the dev UI compose box, answer the 5 dossier questions one by one.
7. Unlock job-bot:
   ```bash
   curl -X POST -H "Authorization: Bearer $WHATSAPP_ORCHESTRATOR_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"coachEmail":"you@example.com"}' \
        http://localhost:7331/admin/silver/users/silver-test/unlock-jobbot
   ```
8. In dev UI: type a free-text question like "What's the CAR method?" — should
   get a Claude-grounded answer (not the canned stub) if `ANTHROPIC_API_KEY` is set.
9. Send `npm run` to invoke `runWeeklyRolePush(scriptDir)` manually OR just wait
   for cron (not yet wired — see follow-up F3 in `decisions-log.md`).

For real WhatsApp tests, drop the `SILVER_TEST_MODE=1`. You'll need a Cloudflare
tunnel (or similar) so Hotmart can reach `/webhooks/hotmart`.

---

## File index

| What | Where |
|---|---|
| Scheduler heartbeat | `lib/groupJobs.js:335-355` |
| Day counter | `lib/silverStore.js` `elapsedDayNumber` |
| Lesson selection (morning send) | `lib/silverStore.js` `nextLessonToSend` |
| Lesson selection (catch-up) | `lib/silverStore.js` `nextCatchupLesson` |
| Module 1 gate | `lib/silverStore.js` `maybeMarkModule1Complete` |
| Lesson catalog | `data/silver/lessons.json` |
| Task registry | `data/silver/tasks.json` |
| Bot copy (EN) | `data/silver/messages.en.json` |
| Bot copy (PT) | `data/silver/messages.pt.json` |
| Inbound router | `lib/silverCourse.js` `handleSilverMessage` |
| Q&A agent | `lib/silverAgent.js` |
| Hotmart webhook | `lib/hotmartWebhook.js` |
| Coach admin endpoints | `lib/coachAdmin.js` |
| Job-bot client | `lib/jobbotClient.js` |
| Roles + tracker | `lib/silverRoles.js` |
| Group creation (legacy) | `lib/report.js` `generateAndSendReport` |
| Group creation (Silver) | `lib/silverCourse.js` `startSilverFromHotmart` |
| Schema + helpers | `lib/silverStore.js` |
