Commit Graph

68 Commits

Author SHA1 Message Date
e8d3e8a108 feat(10-01): KB ingestion pipeline - migration, extractors, API router
- Migration 014: add status/error_message/chunk_count to kb_documents, make agent_id nullable
- Add GOOGLE_CALENDAR to ChannelTypeEnum in tenant.py
- Add brave_api_key, firecrawl_api_key, google_client_id/secret, minio_kb_bucket to config
- Add text extractors for PDF, DOCX, PPTX, XLSX/XLS, CSV, TXT, MD
- Add KB management API router with upload, list, delete, URL ingest, reindex endpoints
- Install pypdf, python-docx, python-pptx, openpyxl, pandas, firecrawl-py, youtube-transcript-api
- Update .env.example with new env vars
- Unit tests: test_extractors.py (10 tests) and test_kb_upload.py (7 tests) all pass
2026-03-26 09:05:29 -06:00
46eece580d fix: /admin/users page crash — API response shape mismatch
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Portal E2E (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:01:41 -06:00
58cf5811f5 chore: update portal submodule to 09-02 + lighthouserc fix commits
- Portal now at 067c08b (includes visual regression, a11y scans, fixed Lighthouse CI thresholds)
2026-03-25 22:53:48 -06:00
13dc55d59c fix: React #418 hydration error — suppressHydrationWarning on html tag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:59:11 -06:00
c910230994 fix: PWA manifest auth bypass + i18n formatting error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:45:42 -06:00
a9077e3559 docs(phase-8): complete Mobile + PWA phase execution
Fixed uuid() recursion bug, updated MOB-02 requirement text.
All 8 phases complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:39:04 -06:00
81a2ce1498 feat(08-03): push subscription client, service worker handlers, install prompt, offline queue
- Service worker push/notificationclick handlers with conversation deep-link
- PushPermission component for opt-in UI in More sheet
- InstallPrompt component (second-visit, Android + iOS)
- IndexedDB message-queue for offline message persistence
- use-chat-socket.ts: drain queue on reconnect, enqueue when offline
2026-03-25 21:30:29 -06:00
7d3a393758 feat(08-03): push notification backend — DB model, migration, API router, VAPID setup
- Add PushSubscription ORM model with unique(user_id, endpoint) constraint
- Add Alembic migration 012 for push_subscriptions table
- Add push router (subscribe, unsubscribe, send) in shared/api/push.py
- Mount push router in gateway/main.py
- Add pywebpush to gateway dependencies for server-side VAPID delivery
- Wire push trigger into WebSocket handler (fires when client disconnects mid-stream)
- Add VAPID keys to .env / .env.example
- Add push/install i18n keys in en/es/pt message files
2026-03-25 21:26:51 -06:00
5c30651754 docs(08-01): complete mobile PWA foundation plan
- Add 08-01-SUMMARY.md: responsive tab bar + PWA infra with K monogram icons
- Update STATE.md: phase 8 plan 1 progress, decisions, metrics
- Update ROADMAP.md: phase 8 in progress (1/4 SUMMARY files)
- Mark requirements MOB-01, MOB-02, MOB-04 complete
- Update portal submodule pointer to acba978 (mobile nav + PWA commits)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:20:45 -06:00
17f6d7cb4b fix: streaming timeout + WebSocket close guard
- Streaming httpx client uses 300s read timeout (cloud LLMs can take
  30-60s for first token). Was using 120s general timeout.
- Guard all WebSocket sends with try/except for client disconnect.
  Prevents "Cannot send once close message has been sent" crash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:39:32 -06:00
dd80e2b822 perf: bypass Celery for web chat — stream LLM directly from WebSocket
Eliminates 5-10s of overhead by calling the LLM pool's streaming
endpoint directly from the WebSocket handler instead of going through
Celery queue → worker → asyncio.run() → Redis pub-sub → WebSocket.

New flow: WebSocket → agent lookup → memory → LLM stream → WebSocket
Old flow: WebSocket → Celery → worker → DB → memory → LLM → Redis → WebSocket

Memory still saved (Redis sliding window + fire-and-forget embedding).
Slack/WhatsApp still use Celery (async webhook pattern).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:32:16 -06:00
2116059157 fix: NullPool for Celery workers + skip pgvector on first message
- Celery workers use NullPool to avoid "Future attached to a different
  loop" errors from stale pooled async connections across asyncio.run()
  calls. FastAPI keeps regular pool (single event loop, safe to reuse).
- Skip pgvector similarity search when no conversation history exists
  (first message) — saves ~3s embedding + query overhead.
- Wrap pgvector retrieval in try/except to prevent DB errors from
  blocking the LLM response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:21:19 -06:00
5e4d9ce144 chore: update portal submodule ref to include streaming changes 2026-03-25 17:57:23 -06:00
61b8762bac feat(streaming): update WebSocket handler to forward streaming chunks to browser
- Pub-sub loop now handles 'chunk' and 'done' message types (not just 'response')
- 'chunk' messages are forwarded immediately via websocket.send_json
- 'done' message breaks the loop and triggers DB persistence of full response
- Sends final 'done' JSON to browser to signal stream completion
- Legacy 'response' type no longer emitted from orchestrator (now unified as 'done')
2026-03-25 17:57:08 -06:00
5fb79beb76 feat(streaming): wire streaming path through orchestrator task pipeline
- _stream_agent_response_to_redis() publishes chunk/done messages to Redis pub-sub
- _process_message() uses streaming path for web channel with no tools registered
- Non-web channels (Slack, WhatsApp) and tool-enabled agents use non-streaming run_agent()
- streaming_delivered flag prevents double-publish when streaming path is active
- _send_response() web branch changed from 'response' to 'done' message type for consistency
2026-03-25 17:57:04 -06:00
9090b54f43 feat(streaming): add run_agent_streaming() to orchestrator runner
- run_agent_streaming() calls POST /complete/stream and yields token strings
- Reads NDJSON lines from the streaming response, yields on 'chunk' events
- On 'error' or connection failure, yields the fallback response string
- Tool calls are not supported in the streaming path
- Existing run_agent() (non-streaming, tool-call loop) is unchanged
2026-03-25 17:57:00 -06:00
f3e358b418 feat(streaming): add complete_stream() generator and POST /complete/stream NDJSON endpoint to llm-pool
- complete_stream() in router.py yields token strings via acompletion(stream=True)
- POST /complete/stream returns NDJSON: chunk lines then a done line
- Streaming path does not support tool calls (plain text only)
- Non-streaming POST /complete endpoint unchanged
2026-03-25 17:56:56 -06:00
b6c8da8cca fix: increase WebSocket pub-sub timeout from 60s to 180s
LLM responses can take >60s (especially with local models). The
WebSocket listener was timing out before the response arrived,
causing agent replies to appear in logs but not in the chat UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:48:22 -06:00
ebe8a9d974 fix: login page crash — LanguageSwitcher split for pre-auth context
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:31:48 -06:00
9654982433 feat(07-01): localized emails, locale-aware templates API, language preference endpoint
- email.py: send_invite_email() adds language param (en/es/pt), sends localized subject+body
- templates.py: list_templates()/get_template() accept ?locale= param, merge translations on response
- portal.py: PATCH /api/portal/users/me/language endpoint persists language preference
- portal.py: /api/portal/auth/verify response includes user.language field
- portal.py: AuthVerifyResponse adds language field (default 'en')
- test_portal_auth.py: fix _make_user mock to set language='en' (auto-fix Rule 1)
- test_language_preference.py: 4 integration tests for language preference endpoint
- test_templates_i18n.py: 5 integration tests for locale-aware templates (all passing)
2026-03-25 16:27:14 -06:00
7a3a4f0fdd feat(07-01): DB migration 009, ORM updates, and LANGUAGE_INSTRUCTION in system prompts
- Migration 009: adds language col (VARCHAR 10, NOT NULL, default 'en') to portal_users
- Migration 009: adds translations col (JSONB, NOT NULL, default '{}') to agent_templates
- Migration 009: backfills es+pt translations for all 7 seed templates
- PortalUser ORM: language mapped column added
- AgentTemplate ORM: translations mapped column added
- system_prompt_builder.py: LANGUAGE_INSTRUCTION constant + appended before AI_TRANSPARENCY_CLAUSE
- system-prompt-builder.ts: LANGUAGE_INSTRUCTION constant + appended before AI transparency clause
- tests: TestLanguageInstruction class with 3 tests (all pass, 20 total)
2026-03-25 16:22:53 -06:00
ebfcb8a24c fix: template library and all API calls wait for session before fetching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:23:47 -06:00
b3635ae34d fix: align all three New Employee cards at same height
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:13:20 -06:00
7ef727f968 fix: Recommended badge no longer clips outside Templates card
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:09:26 -06:00
35131e353b fix: eliminate 422 race condition — RBAC headers sync before queries
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:05:46 -06:00
ee1c2f70f8 fix: set RLS tenant context for chat conversation lookups
Chat API queries on web_conversations need tenant context set before
RLS policies allow the SELECT. Also fixes crypto.randomUUID fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:34:38 -06:00
5b02b233f3 fix: chat WebSocket connects to correct remote host
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:26:36 -06:00
ebf6e76174 feat: make Ollama model configurable via OLLAMA_MODEL env var
- Add OLLAMA_MODEL setting to shared config (default: qwen3:32b)
- LLM router reads from settings instead of hardcoded model name
- Create .env file with all configurable settings documented
- docker-compose passes OLLAMA_MODEL to llm-pool container

To change the model: edit OLLAMA_MODEL in .env and restart llm-pool.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:22:18 -06:00
22c6a44ff6 fix: map all model_preference values to LiteLLM router groups
Added balanced/economy/local groups alongside fast/quality so all 5
agent model_preference values resolve to real provider groups.
All default to local Ollama qwen3:32b, commercial as fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:20:23 -06:00
2444c61022 fix: chat page shows tenant picker for platform admins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:10:49 -06:00
84d2e775ad fix: register RLS hook on gateway — agent creation was failing with policy violation
The gateway never called configure_rls_hook(engine), so SET LOCAL
app.current_tenant was never set for any DB operation through the
portal API endpoints. All tenant-scoped writes (agent creation, etc.)
failed with "new row violates row-level security policy."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:40:08 -06:00
2127d1a844 feat: portal font upgrade — DM Sans + JetBrains Mono
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:19:33 -06:00
01e685b18b feat: premium portal UI — glass-morphism and luminous design system
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:15:58 -06:00
012566c8ee feat: portal UI revamp — brand identity and visual polish
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:54:33 -06:00
56c11a0f1a feat(06-01): WebSocket endpoint, chat REST API, orchestrator wiring, gateway mounting
- Create gateway/channels/web.py with normalize_web_event() and /chat/ws/{conversation_id}
  WebSocket endpoint (auth via first JSON message, typing indicator, Redis pub-sub response)
- Create shared/api/chat.py with GET/POST/DELETE /api/portal/chat/conversations* REST API
  with require_tenant_member RBAC enforcement and RLS context var setup
- Add chat_router to shared/api/__init__.py exports
- Mount chat_router and web_chat_router in gateway/main.py (Phase 6 Web Chat routers)
- All 19 unit tests pass; full 313-test suite green
2026-03-25 10:26:54 -06:00
c72beb916b feat(06-01): add web channel type, Redis key, ORM models, migration, and tests
- Add ChannelType.WEB = 'web' to shared/models/message.py
- Add webchat_response_key() to shared/redis_keys.py
- Create WebConversation and WebConversationMessage ORM models (SQLAlchemy 2.0)
- Create migration 008_web_chat.py with RLS, indexes, and channel_type CHECK update
- Pop conversation_id/portal_user_id extras in handle_message before model_validate
- Add web case to _build_response_extras and _send_response (Redis pub-sub publish)
- Import webchat_response_key in orchestrator/tasks.py
- Write 19 unit tests covering CHAT-01 through CHAT-05 (all pass)
2026-03-25 10:26:34 -06:00
f9ce3d650f feat(05-01): template list/detail/deploy API + RBAC + integration tests
- Create shared/api/templates.py with templates_router
- GET /api/portal/templates: list active templates (any authenticated user)
- GET /api/portal/templates/{id}: get template detail (any authenticated user)
- POST /api/portal/templates/{id}/deploy: create Agent snapshot (tenant_admin only)
- customer_operator returns 403 on deploy (RBAC enforced)
- Export templates_router from shared/api/__init__.py
- Mount templates_router in gateway/main.py (Phase 5 section)
- 11 integration tests pass (list, detail, deploy, RBAC, 404, snapshot independence)
2026-03-24 20:32:30 -06:00
d1acb292a1 feat(05-01): AgentTemplate ORM model, migration 007, and system prompt builder
- Add AgentTemplate ORM model to tenant.py (global, not tenant-scoped)
- Create migration 007 with agent_templates table and 7 seed templates
- Create shared/prompts/system_prompt_builder.py with build_system_prompt()
- AI transparency clause always present (non-negotiable per Phase 1 decision)
- Unit tests pass (17 tests, all sections verified)
2026-03-24 20:27:54 -06:00
188ef4f6e1 fix: runtime deployment — CORS, Slack guard, litellm GitHub, CPU torch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:31:57 -06:00
43b73aa6c5 feat(04-rbac-03): wire RBAC guards to all portal API endpoints + new endpoints
- Add require_platform_admin guard to GET/POST /tenants, PUT/DELETE /tenants/{id}
- Add require_tenant_member to GET /tenants/{id}, GET agents, GET agent/{id}
- Add require_tenant_admin to POST agents, PUT/DELETE agents
- Add require_tenant_admin to billing checkout and portal endpoints
- Add require_tenant_admin to channels slack/install and whatsapp/connect
- Add require_tenant_member to channels /{tid}/test
- Add require_tenant_admin to all llm_keys endpoints
- Add require_tenant_member to all usage GET endpoints
- Add POST /tenants/{tid}/agents/{aid}/test (require_tenant_member for operators)
- Add GET /tenants/{tid}/users with pending invitations (require_tenant_admin)
- Add GET /admin/users with tenant filter/role filter (require_platform_admin)
- Add POST /admin/impersonate with AuditEvent logging (require_platform_admin)
- Add POST /admin/stop-impersonation with AuditEvent logging (require_platform_admin)
2026-03-24 17:13:35 -06:00
d59f85cd87 feat(04-rbac-01): RBAC guards + invite token + email + invitation API
- rbac.py: PortalCaller dataclass + get_portal_caller dependency (header-based)
- rbac.py: require_platform_admin (403 for non-platform_admin)
- rbac.py: require_tenant_admin (platform_admin bypasses; customer_admin
  checks UserTenantRole; operator always rejected)
- rbac.py: require_tenant_member (platform_admin bypasses; all roles
  checked against UserTenantRole)
- invite_token.py: generate_invite_token (HMAC-SHA256, base64url, 48h TTL)
- invite_token.py: validate_invite_token (timing-safe compare_digest, TTL check)
- invite_token.py: token_to_hash (SHA-256 for DB storage)
- email.py: send_invite_email (sync smtplib, skips if smtp_host empty)
- invitations.py: POST /api/portal/invitations (create, requires tenant admin)
- invitations.py: POST /api/portal/invitations/accept (accept invitation)
- invitations.py: POST /api/portal/invitations/{id}/resend (regenerate token)
- invitations.py: GET /api/portal/invitations (list pending)
- portal.py: AuthVerifyResponse now returns role+tenant_ids+active_tenant_id
- portal.py: auth/register gated behind require_platform_admin
- tasks.py: send_invite_email_task Celery task (fire-and-forget)
- gateway/main.py: invitations_router mounted
2026-03-24 13:52:45 -06:00
f710c9c5fe feat(04-rbac-01): DB migration 006 + RBAC ORM models + config fields
- Migration 006: adds role TEXT+CHECK column to portal_users, backfills
  is_admin -> platform_admin/customer_admin, drops is_admin
- Migration 006: creates user_tenant_roles table (UNIQUE user_id+tenant_id)
- Migration 006: creates portal_invitations table with token_hash, status, expires_at
- PortalUser: replaced is_admin (bool) with role (str, default customer_admin)
- Added UserRole enum (PLATFORM_ADMIN, CUSTOMER_ADMIN, CUSTOMER_OPERATOR)
- Added UserTenantRole ORM model with FK cascade deletes
- Added PortalInvitation ORM model with token_hash unique constraint
- Settings: added invite_secret, smtp_host, smtp_port, smtp_username,
  smtp_password, smtp_from_email fields
2026-03-24 13:49:16 -06:00
0e0ea5fb66 fix: runtime deployment fixes for Docker Compose stack
- Add .gitignore for __pycache__, node_modules, .playwright-mcp
- Add CLAUDE.md project instructions
- docker-compose: remove host port exposure for internal services,
  remove Ollama container (use host), add CORS origin, bake
  NEXT_PUBLIC_API_URL at build time, run alembic migrations on
  gateway startup, add CPU-only torch pre-install
- gateway: add CORS middleware, graceful Slack degradation without
  bot token, fix None guard on slack_handler
- gateway pyproject: add aiohttp dependency for slack-bolt async
- llm-pool pyproject: install litellm from GitHub (removed from PyPI),
  enable hatch direct references
- portal: enable standalone output in next.config.ts
- Remove orphaned migration 003_phase2_audit_kb.py (renamed to 004)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:26:34 -06:00
7c8d219835 fix(03-05): fix Slack OAuth and budget alert field name mismatches
- Slack callback: check data.ok (not data.success) to match backend response
- SlackInstallResponse: use url + state fields (not authorize_url)
- connect-channel.tsx: update all authorize_url refs to url
- BudgetAlert: use current_usd (not current_cost_usd) to match backend Pydantic model
- usage page: update alert.current_cost_usd to alert.current_usd
2026-03-24 00:54:21 -06:00
c47cc2f5bf feat(03-05): mount Phase 3 API routers on gateway FastAPI app
- Import all 6 Phase 3 routers from shared.api (portal, billing, channels, llm_keys, usage, webhook)
- Add include_router() calls after existing whatsapp_router
- Update module docstring to document portal API endpoints
2026-03-24 00:53:32 -06:00
11c1e52ea0 feat(03-02): onboarding wizard, Slack OAuth, WhatsApp connect, and BYO API keys UI
- Slack OAuth callback route handler (/api/slack/callback)
- Onboarding wizard: 3-step stepper (connect channel -> configure agent -> test message)
- Connect Channel: Slack OAuth button + WhatsApp manual credentials form
- Configure Agent: links to Agent Designer, Next enabled only with active agent
- Test Message: per-channel test buttons, required step, no separate Go Live button
- BYO API key management settings page at /settings/api-keys
- API Keys nav link in sidebar
- recharts installed (was missing, blocked portal build)
2026-03-23 21:48:06 -06:00
3c8fc255bc feat(03-01): LLM key CRUD API endpoints with encryption
- Create llm_keys.py: GET list (redacted, key_hint only), POST (encrypt + store), DELETE (204 or 404)
- LlmKeyResponse never exposes encrypted_key or raw api_key
- 409 returned on duplicate (tenant_id, provider) key
- Cross-tenant deletion prevented by tenant_id verification in DELETE query
- Update api/__init__.py to export llm_keys_router
- All 5 LLM key CRUD tests passing (32 total unit tests green)
2026-03-23 21:36:08 -06:00
4cbf192fa5 feat(03-01): backend API endpoints — channels, billing, usage, and audit logger enhancement
- Create channels.py: HMAC-signed OAuth state generation/verification, Slack OAuth install/callback, WhatsApp manual connect, test message endpoint
- Create billing.py: Stripe Checkout session, billing portal session, webhook handler with idempotency (StripeEvent table), subscription lifecycle management
- Update usage.py: add _aggregate_rows_by_agent and _aggregate_rows_by_provider helpers (unit-testable without DB), complete usage endpoints
- Fix audit.py: rename 'metadata' attribute to 'event_metadata' (SQLAlchemy 2.0 DeclarativeBase reserves 'metadata')
- Enhance runner.py: audit log now includes prompt_tokens, completion_tokens, total_tokens, cost_usd, provider in LLM call metadata
- Update api/__init__.py to export all new routers
- All 27 unit tests passing
2026-03-23 21:24:08 -06:00
215e67a7eb feat(03-01): DB migrations, models, encryption service, and test scaffolds
- Add stripe and cryptography to shared pyproject.toml
- Add recharts, @stripe/stripe-js, stripe to portal package.json (submodule)
- Add billing fields to Tenant model (stripe_customer_id, subscription_status, agent_quota, trial_ends_at)
- Add budget_limit_usd to Agent model
- Create TenantLlmKey and StripeEvent models in billing.py (AuditBase and Base respectively)
- Create KeyEncryptionService (MultiFernet encrypt/decrypt/rotate) in crypto.py
- Create compute_budget_status helper in usage.py (threshold logic: ok/warning/exceeded)
- Add platform_encryption_key, stripe_, slack_oauth settings to config.py
- Create Alembic migration 005 with all schema changes, RLS, grants, and composite index
- All 12 tests passing (key encryption roundtrip, rotation, budget thresholds)
2026-03-23 21:19:09 -06:00
bd217a4113 feat(02-06): re-wire escalation and WhatsApp outbound routing in pipeline
- Move key imports to module level in tasks.py for testability and clarity
- Pop WhatsApp extras (phone_number_id, bot_token) in handle_message before model_validate
- Build unified extras dict and extract wa_id from sender.user_id
- Change _process_message signature to accept extras dict
- Add _build_response_extras() helper for channel-aware extras assembly
- Replace all _update_slack_placeholder calls in _process_message with _send_response()
- Add escalation pre-check: skip LLM when Redis escalation_status_key == 'escalated'
- Add escalation post-check: check_escalation_rules after run_agent; call escalate_to_human
  when rule matches and agent.escalation_assignee is set
- Add _build_conversation_metadata() helper (billing keyword v1 detection)
- Add channel parameter to build_system_prompt(), build_messages_with_memory(),
  build_messages_with_media() for WhatsApp tier-2 business-function scoping
- WhatsApp scoping appends 'You only handle: {topics}' when tool_assignments non-empty
- Pass msg.channel to build_messages_with_memory() in _process_message
- All 26 new tests pass; all existing escalation/WhatsApp tests pass (no regressions)
2026-03-23 19:15:20 -06:00