# Next Session: WhatsApp Agent — Hardening + Observability

**Date:** 2026-04-07
**Status:** FULL E2E VERIFIED — Annie replied to WhatsApp DM via u2 delivery

## What Happened (Session 2026-04-07)

**Five commits deployed** to fix the full Tasker → receiver → LLM → u2 delivery pipeline:

| Commit | Fix |
|--------|-----|
| `f6b8f7e` | Tasker watchdog — auto-restart dead Tasker via `pidof` + `am start` |
| `0417a3e` | Titan IP fix — `.55` → `.52` in config.py + Panda `.env` |
| `90adead` | Disable dedup + add Tasker variable guard (Layer 3) |
| `7533d78` | Reply to correct chat (`msg.group`) + auto-unlock Pixel via PIN |
| (Tasker UI) | Changed body from `%antitle`/`%antext` → `%evtprm2`/`%evtprm3` |

**E2E verified at 05:33 IST:**
```
Rajesh → "Annie, what time is it?"
→ Tasker POST (evtprm2=Rajesh, evtprm3=message)
→ Receiver buffered (24 chars)
→ Trigger regex matched
→ LLM classified: direct_request
→ Generated: "It's 3:15 PM right now, Rajesh!"
→ Unlocked phone via PIN
→ Message sent to 'Rajesh' in WhatsApp ✅
```

## What Needs Fixing

### 1. Re-enable Dedup (HIGH — data quality)

Dedup was disabled to debug Tasker variable substitution. Now that variables work, re-enable it.

**Files:**
- `services/whatsapp-agent/receiver.py` — uncomment Layer 5 (dedup check)
- `services/whatsapp-agent/tests/test_receiver.py` — unskip `test_duplicate_rejected`

```python
# In receiver.py, uncomment:
# Layer 5: Dedup (F14 — async wrapper for disk I/O)
is_dup = await asyncio.to_thread(is_duplicate, msg.group, resolved_sender, msg.text)
if is_dup:
    logger.debug("[RECEIVER] Filtered: duplicate from {}", resolved_sender)
    return JSONResponse(
        {"status": "filtered", "reason": "duplicate"},
        status_code=422,
    )
```

### 2. Inject Real Time into Response Prompt (HIGH — correctness)

LLM generated "3:15 PM" when it was actually 5:33 AM. Gemma 4 has no real-time clock — it hallucinates plausible times.

**Fix in `services/whatsapp-agent/responder.py`:**

```python
# In generate_response(), add current time to prompt context:
from datetime import datetime

current_time = datetime.now().strftime("Current date/time: %A, %B %d, %Y at %I:%M %p IST")
prompt = RESPONSE_PROMPT.format(
    group=GROUP_NAME,
    context=f"{current_time}\n\n{context}",
    sender=sender,
    text=text,
    guard=COMPACTION_PROMPT_GUARD,
)
```

### 3. Wire wa_observability Emit Calls (MEDIUM — monitoring)

The emit functions are defined in `wa_observability.py` but not called anywhere.

**In `services/whatsapp-agent/agent.py`, add 4 emit points:**

```python
# At top:
from wa_observability import (
    emit_message_received,
    emit_compaction,
    emit_trigger,
    emit_response,
)

# In _drain_buffer_loop, after buffering each message:
emit_message_received(msg["sender"], len(msg["text"]))

# In _check_trigger, after classification:
emit_trigger(sender, classification)

# In _check_trigger, after send_response:
emit_response(sender, len(response), sent)

# In _hourly_compaction_loop, after successful compaction:
emit_compaction(hour_label, len(messages), len(digest))
```

### 4. Handle `%evtprm2`="WhatsApp" Edge Case (MEDIUM — reliability)

The first notification event sometimes has the app name ("WhatsApp") as `%evtprm2` instead of the sender name. This was seen at 05:30:02:

```
[RECEIVER] Buffered message from WhatsApp (25 chars, buffer=1)
[RECEIVER] Buffered message from Rajesh (24 chars, buffer=2)
```

**Options:**
- Filter messages where `sender == "WhatsApp"` (noise — app-level notification)
- Add "WhatsApp" to the noise/sender blocklist in config.py
- Use `%evtprm1` instead for app detection and filter accordingly

### 5. Fix phoenix Mismatch (LOW — test hygiene)

`annie-voice/observability.py` has phoenix as `acting/indicf5-tts` but `chronicler.py` has it as `thinking/daily-reflection`. Causes 3 TS + 2 Python test failures in cross-service sync tests.

**Options:**
- Rename IndicF5 phoenix → "firebird"
- Split into phoenix (daily-reflection) + phoenix-tts (indicf5)

### 6. Response Prompt Hardcodes "Family Group" (LOW — polish)

`RESPONSE_PROMPT` says "WhatsApp family group" but the reply target can now be a DM chat. Should be dynamic based on whether it's a group or DM.

```python
# In responder.py, update RESPONSE_PROMPT:
# If group == sender → it's a DM, not a group
context_type = "a direct WhatsApp chat" if group == sender else f"a WhatsApp group called \"{group}\""
```

## Environment

- **Panda** (192.168.68.57): WhatsApp agent on port 8780, phone services
- **Titan** (192.168.68.52): Gemma 4 on port 8003, Context Engine on port 8100 (**NOTE: IP is .52, not .55**)
- **Pixel** (192.168.68.60): WhatsApp + Tasker, USB-connected to Panda (serial `62271XEBF9DFD4`)
- **Pixel PIN**: `~/.her-os/pixel-pin` on Panda (chmod 600)

## Key Files

| File | What |
|------|------|
| `services/whatsapp-agent/agent.py` | Main daemon — wire emit calls here |
| `services/whatsapp-agent/responder.py` | Response gen + u2 delivery + `_unlock_if_locked()` |
| `services/whatsapp-agent/receiver.py` | FastAPI receiver — dedup disabled (Layer 5 commented) |
| `services/whatsapp-agent/wa_observability.py` | Standalone emitter (emit functions defined) |
| `services/whatsapp-agent/tasker_watchdog.py` | Tasker process monitor + auto-restart |
| `services/whatsapp-agent/config.py` | Constants, circuit breaker, Tasker package |
| `/tmp/whatsapp-agent.log` | Agent log on Panda |

## Verification

```bash
# Check health (should show tasker_alive: true, llm_circuit_breaker: closed)
ssh panda "curl -s http://localhost:8780/v1/health | python3 -m json.tool"

# Send test and watch logs
ssh panda "tail -f /tmp/whatsapp-agent.log"
# Then send WhatsApp DM: "Annie, what's the weather like?"

# Check delivery in WhatsApp on Pixel
ssh panda "adb -s 62271XEBF9DFD4 shell screencap -p /sdcard/check.png && adb -s 62271XEBF9DFD4 pull /sdcard/check.png /tmp/check.png"
```
