cambium ▸ docs/superpowers/specs/2026-04-12-bl008v2-cortex-invocation-design.md
updated 2026-04-12

BL-008 v2 — Cortex Invocation from Persona Router

Date: 2026-04-12 Status: Draft — awaiting review Touches: @cambium/cortex, @cambium/providers, deployments/obadiah/bridge/start-rhythms.ts

Purpose

Close the loop: a Discord message that matches a persona now gets a real response from that persona, powered by the full Cortex pipeline (intent decompose → capability resolve → response generation), with the reply posted as a threaded Discord message. V1 (BL-008 v1) captured routing decisions as memory marks. V2 makes the personas actually speak.

Design Decisions (locked by Joshua on 2026-04-12)

  1. LLM provider: Anthropic, API key auth (ANTHROPIC_API_KEY env var). Intelligent model routing: Haiku for triage (routing tiebreakers, heartbeat checks, lightweight classification), Sonnet for general persona work (composing replies, understanding context), Opus only for complex multi-step reasoning (explicitly requested tasks, deep analysis). Uses the existing @cambium/providers ProviderRegistry with capability-based lookup + cost ranking.
  2. Reply surface: Same Discord channel, threaded reply.
  3. Multi-match disambiguation: LLM tiebreaker. One Haiku call to pick the most relevant persona. All matches still captured as memory marks (BL-008 v1 behavior preserved).
  4. Error handling: Errors logged to a dedicated store and fed back into the composting/learning loop. Failed invocations should become data, not silent losses.

Architecture

The invocation pipeline

Discord message arrives as signal
    ↓
Persona router matches N personas against domain_triggers (existing v1 code)
    ↓
For each match: emit memory.observation (existing v1 code — preserved)
    ↓
[NEW] If N > 1: make one Haiku LLM call to tiebreak
         Prompt: "Given message '<content>' and these candidate personas: <list with descriptions>,
                  which single persona is the best fit? Return just the persona id."
         This costs ~0.001¢ per tiebreak
    ↓
[NEW] Selected persona → invoke Cortex response generation
         This is NOT the full Orchestrator.submitIntent pipeline.
         Why: the full pipeline (intent decompose → DAG graph → multi-step workflow) is
         designed for complex multi-step tasks. A Discord message response is simpler —
         it's a single LLM call with persona context, memory recall, and the message.
         The full Orchestrator pipeline is reserved for explicit multi-step intents
         (e.g. "research the best restaurants in Berlin and send me a summary").
    ↓
[NEW] Response generation (one Sonnet call):
         System prompt: persona's character description (from agents.yaml or personas/*.md)
         Context: recall() results for the message's subject (from memory)
         User message: the Discord message content
         → LLM generates a response in the persona's voice
    ↓
[NEW] Post response as threaded Discord reply via DiscordClient
    ↓
[NEW] Emit persona.responded signal (for downstream tracking, memory capture)

Why NOT the full Orchestrator

The Orchestrator.submitIntent() pipeline was designed for complex, multi-step task execution (decompose intent → build DAG → spawn agents → execute graph nodes → collect results). A Discord message response is a single-turn conversation: understand the message, recall relevant context, compose a reply in the persona’s voice, post it. Using the full Orchestrator for this would be massive overkill — like using a CNC machine to cut a sandwich.

The Orchestrator stays available for explicitly multi-step intents that arrive via other surfaces (CLI, Loom UI, scheduled workflows). Those are separate use cases from “Arnold replies to a Discord message about sleep.”

Model routing strategy

Task type Model Rationale
Routing tiebreaker (which persona wins a multi-match) Haiku Simple classification, fast, cheap (~0.001¢)
Persona response composition Sonnet Good writing, good reasoning, cost-effective for conversational replies
Complex multi-step intent decomposition (via Orchestrator, future) Opus Needs deep reasoning about task graphs; worth the cost for complex work
Heartbeat checks, signal classification Haiku Trivial tasks, pure classification
Memory consolidation (nightly daily→marks) Sonnet Needs reading comprehension and judgment about what’s worth marking

These are wired as provider_kind values in the ProviderRegistry. The brain registers three Anthropic providers at boot with different model IDs and capability sets. The existing BudgetEnforcer enforces per-workspace cost ceilings.

Persona context loading

When generating a response, the system loads: 1. Persona identity — from the agents.yaml persona entry (short description + domain_triggers + capabilities) OR from the richer /Obadiah/personas/<name>.md markdown profile if it exists 2. Memory recallrecall({ about: <inferred subject from message>, horizon: '7d', workspace_id }) returns substrate excerpts, recent marks, daily hits, compost learnings relevant to the topic 3. Conversation history — last N messages from the same Discord thread (if threaded) or channel (if top-level). N=5 for v1. Fetched via DiscordClient.

These three sources compose the LLM prompt’s system/context sections.

Error handling → composting

When a persona invocation fails at any stage (LLM call fails, tiebreaker fails, Discord post fails):

  1. Emit cortex.invocation_failed signal with payload: { persona, message_id, error_message, stage (tiebreak | generate | post), signal_id }
  2. A receptor catches this and writes to a new invocation_errors Drizzle table: id, workspace_id, persona, signal_id, stage, error_message, created_at, resolved_at, resolution
  3. The error pattern also gets fed into Genesis composting — if a persona template consistently fails on a specific kind of message (e.g. “Arnold can’t handle messages about workout equipment”), that failure pattern becomes a perennial learning that influences future routing.
  4. Errors are visible via a new CLI command: obadiah cortex errors [--unresolved]
  5. No retry. V1 fails once and logs. The user can manually re-trigger if needed. Automatic retry with backoff is a v2 optimization.

Discord threading

When the DiscordClient posts a reply: - If the original message was in a thread: reply in that thread. - If the original message was top-level: create a new thread from the original message and post the reply there. This keeps the channel clean — persona replies don’t clutter the main channel flow. - Thread name: "<persona_name> responding" (e.g. “Arnold responding”).

Data Model

New table: invocation_errors

invocation_errors
  id                text pk
  workspace_id      text not null
  persona           text not null       -- persona id from agents.yaml
  signal_id         text not null       -- the discord.message_created signal that triggered this
  stage             text not null       -- 'tiebreak' | 'generate' | 'post'
  error_message     text not null
  stack_trace       text                -- nullable
  created_at        timestamptz not null
  resolved_at       timestamptz         -- null until manually resolved
  resolution        text                -- 'fixed' | 'dismissed' | 'composted'

  indexes:
    - workspace_id
    - (workspace_id, persona)
    - resolved_at  -- for filtering unresolved

New env vars needed

Changes to spore.yaml

# New optional field — persona profile directory (richer markdown profiles)
paths:
  persona_profiles: /Users/joshua/Projects/Obadiah/personas  # optional; agents.yaml is always primary

What Ships in v1

  1. PersonaInvoker module at packages/cortex/src/persona-invoker.ts — the function that takes a matched persona + a Discord signal and produces a response via one Sonnet LLM call
  2. LLM tiebreaker at packages/cortex/src/persona-tiebreaker.ts — one Haiku call to pick from multi-match
  3. Provider boot — three Anthropic providers registered in start-rhythms.ts (haiku for triage, sonnet for general, opus for complex)
  4. Discord reply posting — extend DiscordClient with a replyInThread(channelId, messageId, content) method
  5. invocation_errors table + migration
  6. Error receptor — catches cortex.invocation_failed signals, writes to invocation_errors
  7. Persona profile loading — reads the richer /Obadiah/personas/*.md if configured in spore
  8. Memory recall integration — response generation uses recall() for context
  9. End-to-end integration test — emit a synthetic discord.message_created, mock the LLM to return a fixed response, verify the reply is posted via the DiscordClient mock
  10. Brain wiringstart-rhythms.ts registers the invoker, tiebreaker, error receptor, and providers
  11. Graceful no-op — without ANTHROPIC_API_KEY, persona invocation is skipped with a warning (same pattern as DISCORD_BOT_TOKEN)

Deferred

Item Why Trigger
Full Orchestrator pipeline for multi-step intents Discord responses are single-turn; Orchestrator is for complex DAGs When a “research X and summarize” intent arrives via Discord and single-turn can’t serve it
Automatic retry with backoff V1 fails once and logs When error rate > 5% on a specific persona
Opus auto-escalation V1 always uses Sonnet for responses; some questions deserve Opus When Sonnet responses are observably worse on complex topics (tracked via composting feedback)
Conversation history beyond 5 messages V1 fetches last 5 from Discord thread When persona responses lose thread context visibly
OAuth for Anthropic V1 uses API key; productization needs per-user OAuth When Cambium ships to a second user

Non-Goals

Success Criteria

  1. A synthetic discord.message_created signal with content “I slept terribly last night” → tiebreaker picks Arnold → Sonnet generates a response in Arnold’s voice → DiscordClient posts it as a threaded reply → persona.responded signal emitted → integration test passes
  2. A multi-match message → tiebreaker makes one Haiku call → picks one persona → single reply (not multiple)
  3. A failed LLM call → cortex.invocation_failed signal → invocation_errors row → no crash, no retry, brain continues
  4. Without ANTHROPIC_API_KEY → all persona invocations are skipped with a warning log, routing still works, memory marks still captured
  5. Budget enforcement: if the workspace exceeds its cost ceiling, persona invocations are paused (existing BudgetEnforcer behavior) and the user sees a budget warning in logs