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

BL-008 v2 — Cortex Invocation from Persona Router

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Close the loop on persona routing: when a Discord message matches a persona, the winning persona composes a real reply via a Sonnet LLM call and posts it as a threaded Discord message. Multi-match messages are disambiguated by a single Haiku tiebreaker call. All LLM calls are mocked in tests. Failures are captured in an invocation_errors table and fed into the composting loop.

Architecture: Extends @cambium/cortex with two new modules (persona-tiebreaker.ts, persona-invoker.ts) and a persona profile loader. Extends @cambium/integrations DiscordClient with replyInThread(). Adds invocation_errors table to @cambium/db. Registers three Anthropic providers at boot in start-rhythms.ts. Wraps invocation in try/catch that emits cortex.invocation_failed signals caught by a new error receptor.

Tech Stack: TypeScript strict, ESM, Vitest, discord.js v14, Drizzle ORM, @cambium/providers (ProviderRegistry, AuthenticatedLlmCaller, AdapterRegistry, AnthropicApiAdapter), @cambium/memory (recall), @cambium/signals (SignalBus).

Spec: docs/superpowers/specs/2026-04-12-bl008v2-cortex-invocation-design.md


File Structure

New files:

packages/cortex/src/persona-profile-loader.ts       — loads rich markdown profiles
packages/cortex/src/persona-profile-loader.test.ts
packages/cortex/src/persona-tiebreaker.ts            — LLM tiebreaker for multi-match
packages/cortex/src/persona-tiebreaker.test.ts
packages/cortex/src/persona-invoker.ts               — core invocation module
packages/cortex/src/persona-invoker.test.ts
packages/integrations/src/discord/discord-client.test.ts  — tests for replyInThread
tests/integration/cortex-invocation-e2e.test.ts      — end-to-end integration test

Modified files:

packages/db/src/schema.ts                            — add invocationErrors table
packages/db/drizzle/                                 — new generated migration SQL
packages/cortex/src/index.ts                         — export new modules
packages/cortex/src/persona-router.ts                — wire tiebreaker + invoker
packages/integrations/src/discord/discord-client.ts  — add replyInThread method
packages/integrations/src/index.ts                   — (no change needed, DiscordClient already exported)
deployments/obadiah/bridge/start-rhythms.ts          — provider boot + invoker wiring

Task 1: DB Migration — invocation_errors Table

Goal: Add the invocation_errors Drizzle schema and generate a migration.

Files: - Modify: packages/db/src/schema.ts - Generate: packages/db/drizzle/NNNN_add_invocation_errors.sql

Steps

// ===== Cortex — Invocation Errors =====

export const invocationErrors = pgTable('invocation_errors', {
  id: text('id').primaryKey(),
  workspace_id: text('workspace_id').notNull(),
  persona: text('persona').notNull(),
  signal_id: text('signal_id').notNull(),
  stage: text('stage').notNull(),  // 'tiebreak' | 'generate' | 'post'
  error_message: text('error_message').notNull(),
  stack_trace: text('stack_trace'),
  created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  resolved_at: timestamp('resolved_at', { withTimezone: true }),
  resolution: text('resolution'),  // 'fixed' | 'dismissed' | 'composted'
}, (table) => [
  index('idx_invocation_errors_workspace').on(table.workspace_id),
  index('idx_invocation_errors_workspace_persona').on(table.workspace_id, table.persona),
  index('idx_invocation_errors_resolved_at').on(table.resolved_at),
]);
cd packages/db && pnpm drizzle-kit generate
pnpm --filter @cambium/db migrate
pnpm turbo typecheck --filter=@cambium/db
feat(db): add invocation_errors table for cortex error tracking

Stores failed persona invocations (tiebreak/generate/post stages)
with resolution state for composting feedback loop.

Task 2: Persona Profile Loader

Goal: New module in @cambium/cortex that loads rich markdown persona profiles from /Obadiah/personas/<name>.md if available, falling back to the short description field from agents.yaml.

Files: - Create: packages/cortex/src/persona-profile-loader.ts - Create: packages/cortex/src/persona-profile-loader.test.ts

Implementation

The persona markdown files (e.g. arnold.md) have this structure:

# Arnold - Health & Fitness Persona

*"Stop being a baby. Let's go."*

## Identity
**Name:** Arnold (or "Coach")
**Inspiration:** Arnold Schwarzenegger
**Domain:** Health, fitness, diet, motivation

## Voice
Tough but motivating. Direct. No coddling. ...

## Context: Joshua
- On keto for 6 months, lost 16kg ...

## Approach
1. **Check in regularly** ...

## Delegation Triggers
...

## Boundaries
- Don't give medical advice beyond general fitness ...

The loader reads the full markdown text and returns it as the persona’s system prompt context. If the file does not exist, it returns the short description string from agents.yaml.

import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

export interface PersonaProfile {
  personaKey: string;
  label: string;
  role: string;
  systemContext: string;  // full markdown content or short description fallback
  source: 'markdown' | 'yaml';
}

export interface LoadPersonaProfileOptions {
  /** Directory containing <persona-key>.md files. Undefined = skip markdown lookup. */
  profilesDir: string | undefined;
  /** Fallback description from agents.yaml. */
  yamlDescription: string;
  /** Persona key, e.g. "arnold". */
  personaKey: string;
  /** Persona label, e.g. "Arnold". */
  label: string;
  /** Persona role, e.g. "health-fitness". */
  role: string;
}

/**
 * Load a persona profile. Attempts to read <profilesDir>/<personaKey>.md first.
 * If that file does not exist or profilesDir is undefined, falls back to
 * the short yamlDescription.
 */
export async function loadPersonaProfile(
  opts: LoadPersonaProfileOptions,
): Promise<PersonaProfile> {
  if (opts.profilesDir) {
    const mdPath = resolve(opts.profilesDir, `${opts.personaKey}.md`);
    try {
      const content = await readFile(mdPath, 'utf8');
      if (content.trim().length > 0) {
        return {
          personaKey: opts.personaKey,
          label: opts.label,
          role: opts.role,
          systemContext: content,
          source: 'markdown',
        };
      }
    } catch {
      // File doesn't exist or can't be read — fall through to yaml fallback
    }
  }

  return {
    personaKey: opts.personaKey,
    label: opts.label,
    role: opts.role,
    systemContext: opts.yamlDescription,
    source: 'yaml',
  };
}
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadPersonaProfile } from './persona-profile-loader.js';

describe('loadPersonaProfile', () => {
  const testDir = join(tmpdir(), `persona-profile-test-${Date.now()}`);

  beforeAll(async () => {
    await mkdir(testDir, { recursive: true });
    await writeFile(
      join(testDir, 'arnold.md'),
      '# Arnold\n\n*"Stop being a baby."*\n\n## Voice\nTough but motivating.\n',
    );
  });

  afterAll(async () => {
    await rm(testDir, { recursive: true, force: true });
  });

  it('loads markdown profile when file exists', async () => {
    const profile = await loadPersonaProfile({
      profilesDir: testDir,
      yamlDescription: 'Health, fitness, wellness advisor',
      personaKey: 'arnold',
      label: 'Arnold',
      role: 'health-fitness',
    });

    expect(profile.source).toBe('markdown');
    expect(profile.systemContext).toContain('Stop being a baby');
    expect(profile.systemContext).toContain('## Voice');
    expect(profile.personaKey).toBe('arnold');
  });

  it('falls back to yaml description when markdown file is missing', async () => {
    const profile = await loadPersonaProfile({
      profilesDir: testDir,
      yamlDescription: 'Finance, money, taxes, investment analysis',
      personaKey: 'mackenzie',
      label: 'MacKenzie',
      role: 'finance',
    });

    expect(profile.source).toBe('yaml');
    expect(profile.systemContext).toBe('Finance, money, taxes, investment analysis');
  });

  it('falls back to yaml description when profilesDir is undefined', async () => {
    const profile = await loadPersonaProfile({
      profilesDir: undefined,
      yamlDescription: 'QA, testing, bugs, code quality',
      personaKey: 'cipher',
      label: 'Cipher',
      role: 'qa-testing',
    });

    expect(profile.source).toBe('yaml');
    expect(profile.systemContext).toBe('QA, testing, bugs, code quality');
  });
});
pnpm turbo test --filter=@cambium/cortex -- --run persona-profile-loader
feat(cortex): add persona profile loader with markdown/yaml fallback

Reads rich markdown profiles from /Obadiah/personas/<key>.md when
available, falls back to short agents.yaml description.

Task 3: LLM Tiebreaker (TDD)

Goal: When multiple personas match a Discord message, make one Haiku LLM call to pick the best fit. Returns the winning persona key.

Files: - Create: packages/cortex/src/persona-tiebreaker.ts - Create: packages/cortex/src/persona-tiebreaker.test.ts

Implementation

The tiebreaker uses the same LlmCaller interface already exported from @cambium/cortex (defined in intent-decomposer.ts):

export interface LlmCaller {
  call(prompt: string): Promise<string>;
}
import type { LlmCaller } from './intent-decomposer.js';

export interface TiebreakerCandidate {
  key: string;
  label: string;
  role: string;
  description: string;
}

export interface TiebreakerResult {
  winnerKey: string;
  allCandidateKeys: string[];
}

/**
 * Uses one LLM call (Haiku) to pick the single best-fit persona from
 * a set of candidates that all matched a message's domain triggers.
 *
 * The prompt asks the LLM to return ONLY the persona key — no explanation,
 * no markdown, no punctuation. If the LLM returns something that doesn't
 * match any candidate key, falls back to the first candidate.
 */
export async function runTiebreaker(
  candidates: TiebreakerCandidate[],
  messageContent: string,
  llmCaller: LlmCaller,
): Promise<TiebreakerResult> {
  if (candidates.length === 0) {
    throw new Error('runTiebreaker called with zero candidates');
  }

  if (candidates.length === 1) {
    return {
      winnerKey: candidates[0]!.key,
      allCandidateKeys: [candidates[0]!.key],
    };
  }

  const candidateList = candidates
    .map((c) => `- ${c.key} (${c.label}): ${c.description}`)
    .join('\n');

  const prompt = `You are a routing classifier. Given a user message and a list of specialist personas, pick the single most relevant persona to respond.

User message: "${messageContent}"

Candidate personas:
${candidateList}

Rules:
- Return ONLY the persona key (e.g. "arnold"), nothing else.
- No explanation, no quotes, no punctuation.
- Pick the persona whose domain is the strongest match for the message content.

Best persona key:`;

  const raw = await llmCaller.call(prompt);
  const cleaned = raw.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');

  const allKeys = candidates.map((c) => c.key);
  const winnerKey = allKeys.includes(cleaned) ? cleaned : candidates[0]!.key;

  return { winnerKey, allCandidateKeys: allKeys };
}
import { describe, it, expect, vi } from 'vitest';
import { runTiebreaker, type TiebreakerCandidate } from './persona-tiebreaker.js';
import type { LlmCaller } from './intent-decomposer.js';

function mockLlm(response: string): LlmCaller {
  return { call: vi.fn().mockResolvedValue(response) };
}

const CANDIDATES: TiebreakerCandidate[] = [
  { key: 'arnold', label: 'Arnold', role: 'health-fitness', description: 'Health, fitness, wellness advisor' },
  { key: 'branson', label: 'Branson', role: 'entrepreneurship', description: 'Entrepreneurship, startups, business strategy' },
];

describe('runTiebreaker', () => {
  it('returns winner when LLM picks a valid candidate', async () => {
    const llm = mockLlm('arnold');
    const result = await runTiebreaker(CANDIDATES, 'I slept terribly last night', llm);

    expect(result.winnerKey).toBe('arnold');
    expect(result.allCandidateKeys).toEqual(['arnold', 'branson']);
    expect(llm.call).toHaveBeenCalledOnce();
  });

  it('cleans whitespace and case from LLM response', async () => {
    const llm = mockLlm('  Arnold  \n');
    const result = await runTiebreaker(CANDIDATES, 'I need workout tips', llm);

    expect(result.winnerKey).toBe('arnold');
  });

  it('falls back to first candidate when LLM returns invalid key', async () => {
    const llm = mockLlm('nonexistent_persona');
    const result = await runTiebreaker(CANDIDATES, 'some message', llm);

    expect(result.winnerKey).toBe('arnold');
  });

  it('skips LLM call when only one candidate', async () => {
    const llm = mockLlm('should not be called');
    const result = await runTiebreaker([CANDIDATES[0]!], 'sleep question', llm);

    expect(result.winnerKey).toBe('arnold');
    expect(llm.call).not.toHaveBeenCalled();
  });

  it('throws when called with zero candidates', async () => {
    const llm = mockLlm('');
    await expect(runTiebreaker([], 'hello', llm)).rejects.toThrow('zero candidates');
  });

  it('prompt includes message content and candidate descriptions', async () => {
    const llm = mockLlm('branson');
    await runTiebreaker(CANDIDATES, 'startup funding advice', llm);

    const prompt = (llm.call as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
    expect(prompt).toContain('startup funding advice');
    expect(prompt).toContain('arnold (Arnold): Health, fitness, wellness advisor');
    expect(prompt).toContain('branson (Branson): Entrepreneurship, startups, business strategy');
  });
});
pnpm turbo test --filter=@cambium/cortex -- --run persona-tiebreaker
feat(cortex): add LLM tiebreaker for multi-match persona routing

One Haiku call picks the best-fit persona when multiple personas
match the same message. Falls back to first candidate on invalid
LLM response.

Task 4: PersonaInvoker (TDD)

Goal: Core module that takes a matched persona, a Discord message signal, and memory context, composes a prompt, makes one Sonnet LLM call, and returns the response text.

Files: - Create: packages/cortex/src/persona-invoker.ts - Create: packages/cortex/src/persona-invoker.test.ts

Implementation

import type { Signal } from '@cambium/core';
import type { LlmCaller } from './intent-decomposer.js';
import type { PersonaProfile } from './persona-profile-loader.js';
import type { RecallResult } from '@cambium/memory';

export interface InvokerDeps {
  llmCaller: LlmCaller;
}

export interface InvokerInput {
  persona: PersonaProfile;
  signal: Signal;
  recallResult: RecallResult;
}

export interface InvokerOutput {
  responseText: string;
  personaKey: string;
  promptTokenEstimate: number;
}

/**
 * Compose a full LLM prompt from persona context + memory recall + message,
 * call Sonnet, and return the response text.
 */
export async function invokePersona(
  input: InvokerInput,
  deps: InvokerDeps,
): Promise<InvokerOutput> {
  const { persona, signal, recallResult } = input;
  const messageContent = typeof signal.payload['content'] === 'string'
    ? signal.payload['content']
    : '';
  const author = extractAuthorName(signal);

  const prompt = buildPrompt(persona, messageContent, author, recallResult);
  const promptTokenEstimate = Math.ceil(prompt.length / 4);

  const responseText = await deps.llmCaller.call(prompt);

  return {
    responseText: responseText.trim(),
    personaKey: persona.personaKey,
    promptTokenEstimate,
  };
}

function extractAuthorName(signal: Signal): string {
  const authorUsername = signal.payload['author_username'];
  if (typeof authorUsername === 'string' && authorUsername.length > 0) {
    return authorUsername;
  }
  const author = signal.payload['author'];
  if (author && typeof author === 'object') {
    const name = (author as Record<string, unknown>)['username'];
    if (typeof name === 'string' && name.length > 0) return name;
  }
  return 'someone';
}

function formatRecallContext(recallResult: RecallResult): string {
  const sections: string[] = [];

  if (recallResult.substrate.length > 0) {
    const items = recallResult.substrate
      .slice(0, 5)
      .map((s) => `  - [${s.file}] ${s.excerpt}`)
      .join('\n');
    sections.push(`Long-term knowledge:\n${items}`);
  }

  if (recallResult.marks.length > 0) {
    const items = recallResult.marks
      .slice(0, 5)
      .map((m) => `  - [strength ${m.current_strength.toFixed(2)}] ${m.observation}`)
      .join('\n');
    sections.push(`Recent observations:\n${items}`);
  }

  if (recallResult.daily.length > 0) {
    const items = recallResult.daily
      .slice(0, 3)
      .map((d) => `  - [${d.date}] ${d.excerpt}`)
      .join('\n');
    sections.push(`Daily log entries:\n${items}`);
  }

  if (recallResult.compost.length > 0) {
    const items = recallResult.compost
      .slice(0, 3)
      .map((c) => `  - ${c.learning}`)
      .join('\n');
    sections.push(`Learnings from past experience:\n${items}`);
  }

  return sections.length > 0
    ? sections.join('\n\n')
    : 'No relevant context found.';
}

function buildPrompt(
  persona: PersonaProfile,
  messageContent: string,
  author: string,
  recallResult: RecallResult,
): string {
  const memoryContext = formatRecallContext(recallResult);

  return `You are ${persona.label}, a specialist persona in the Obadiah system.

=== YOUR IDENTITY AND VOICE ===
${persona.systemContext}

=== RELEVANT CONTEXT FROM MEMORY ===
${memoryContext}

=== INSTRUCTIONS ===
- Respond in character as ${persona.label}. Stay in your voice.
- Be helpful and specific. Give actionable advice when appropriate.
- Keep your response concise (1-3 paragraphs). This is a Discord reply, not an essay.
- Address ${author} naturally. Do not use formal greetings like "Dear ${author}".
- Do not mention that you are an AI, a persona, or part of a system.
- Do not reference "memory", "recall", "context", or "signals" — just use the knowledge naturally.

=== MESSAGE FROM ${author.toUpperCase()} ===
${messageContent}

=== YOUR RESPONSE AS ${persona.label.toUpperCase()} ===`;
}
import { describe, it, expect, vi } from 'vitest';
import { invokePersona, type InvokerInput, type InvokerDeps } from './persona-invoker.js';
import type { Signal, SignalId } from '@cambium/core';
import { createId } from '@cambium/core';
import type { PersonaProfile } from './persona-profile-loader.js';
import type { RecallResult } from '@cambium/memory';
import type { LlmCaller } from './intent-decomposer.js';

function mockLlm(response: string): LlmCaller {
  return { call: vi.fn().mockResolvedValue(response) };
}

function makeSignal(content: string, author: string): Signal {
  return {
    id: createId<SignalId>('test-signal-1'),
    type: 'discord.message_created',
    class: 'coordination',
    source_layer: 'discord',
    source_id: 'discord:test',
    workspace_id: 'test-workspace',
    intensity: 0.5,
    decay_rate: 0.1,
    cascade_potential: false,
    payload: { content, author_username: author },
    affinity: ['discord'],
    emitted_at: new Date().toISOString(),
  } as Signal;
}

const ARNOLD_PROFILE: PersonaProfile = {
  personaKey: 'arnold',
  label: 'Arnold',
  role: 'health-fitness',
  systemContext: '# Arnold\n\nTough but motivating. Direct. No coddling.\n\n"Stop being a baby. Let\'s go."',
  source: 'markdown',
};

const EMPTY_RECALL: RecallResult = {
  substrate: [],
  marks: [],
  daily: [],
  compost: [],
  summary: null,
};

const POPULATED_RECALL: RecallResult = {
  substrate: [
    { file: 'long-term/health.md', excerpt: 'Joshua is on keto, lost 16kg', reason: 'health context' },
  ],
  marks: [
    {
      id: 'mark-1' as RecallResult['marks'][0]['id'],
      subject: 'joshua.sleep.quality',
      observation: 'Joshua reported poor sleep 3 times this week',
      current_strength: 0.8,
      observed_by: 'persona-router',
      first_observed_at: '2026-04-09T00:00:00Z',
      last_reinforced_at: '2026-04-11T00:00:00Z',
      evidence: { signal_ids: ['sig-1'] },
    },
  ],
  daily: [
    { date: '2026-04-11', excerpt: 'Slept only 5 hours, feel groggy', line_range: [3, 3] as [number, number] },
  ],
  compost: [],
  summary: null,
};

describe('invokePersona', () => {
  it('calls LLM with composed prompt and returns trimmed response', async () => {
    const llm = mockLlm('  Get to bed earlier, no excuses. Your body needs 7-8 hours minimum.  ');
    const deps: InvokerDeps = { llmCaller: llm };
    const input: InvokerInput = {
      persona: ARNOLD_PROFILE,
      signal: makeSignal('I slept terribly last night', 'joshua'),
      recallResult: EMPTY_RECALL,
    };

    const result = await invokePersona(input, deps);

    expect(result.responseText).toBe('Get to bed earlier, no excuses. Your body needs 7-8 hours minimum.');
    expect(result.personaKey).toBe('arnold');
    expect(result.promptTokenEstimate).toBeGreaterThan(0);
    expect(llm.call).toHaveBeenCalledOnce();
  });

  it('includes persona identity in the prompt', async () => {
    const llm = mockLlm('response');
    await invokePersona(
      { persona: ARNOLD_PROFILE, signal: makeSignal('hello', 'joshua'), recallResult: EMPTY_RECALL },
      { llmCaller: llm },
    );

    const prompt = (llm.call as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
    expect(prompt).toContain('You are Arnold');
    expect(prompt).toContain('Stop being a baby');
  });

  it('includes memory recall context in the prompt', async () => {
    const llm = mockLlm('response');
    await invokePersona(
      { persona: ARNOLD_PROFILE, signal: makeSignal('my sleep is bad', 'joshua'), recallResult: POPULATED_RECALL },
      { llmCaller: llm },
    );

    const prompt = (llm.call as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
    expect(prompt).toContain('Joshua is on keto, lost 16kg');
    expect(prompt).toContain('Joshua reported poor sleep 3 times this week');
    expect(prompt).toContain('Slept only 5 hours, feel groggy');
  });

  it('includes the message content and author in the prompt', async () => {
    const llm = mockLlm('response');
    await invokePersona(
      { persona: ARNOLD_PROFILE, signal: makeSignal('I need workout tips', 'joshua'), recallResult: EMPTY_RECALL },
      { llmCaller: llm },
    );

    const prompt = (llm.call as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
    expect(prompt).toContain('I need workout tips');
    expect(prompt).toContain('MESSAGE FROM JOSHUA');
  });

  it('uses "someone" when author is not available', async () => {
    const llm = mockLlm('response');
    const signal = makeSignal('hello', '');
    signal.payload = { content: 'hello' };  // no author fields

    await invokePersona(
      { persona: ARNOLD_PROFILE, signal, recallResult: EMPTY_RECALL },
      { llmCaller: llm },
    );

    const prompt = (llm.call as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
    expect(prompt).toContain('MESSAGE FROM SOMEONE');
  });

  it('shows "No relevant context found" when recall is empty', async () => {
    const llm = mockLlm('response');
    await invokePersona(
      { persona: ARNOLD_PROFILE, signal: makeSignal('hello', 'joshua'), recallResult: EMPTY_RECALL },
      { llmCaller: llm },
    );

    const prompt = (llm.call as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
    expect(prompt).toContain('No relevant context found.');
  });
});
pnpm turbo test --filter=@cambium/cortex -- --run persona-invoker
feat(cortex): add PersonaInvoker for LLM-powered persona responses

Composes a prompt from persona identity, memory recall context, and
message content, calls Sonnet, and returns the response text.

Task 5: Discord replyInThread Method

Goal: Extend DiscordClient with replyInThread(channelId, messageId, content, personaLabel). Handles both “reply in existing thread” and “create thread from message then reply.”

Files: - Modify: packages/integrations/src/discord/discord-client.ts - Create: packages/integrations/src/discord/discord-client.test.ts

Implementation

Add the following imports at the top of the file (extend the existing import):

import {
  Client,
  GatewayIntentBits,
  type Message,
  type ClientOptions,
  type TextChannel,
  type ThreadChannel,
  ChannelType,
} from 'discord.js';

Add this method to the DiscordClient class, after the stop() method:

  /**
   * Post a response as a threaded reply to a message.
   *
   * - If the original message is already in a thread, post in that thread.
   * - If the original message is top-level, create a new thread from the
   *   message and post the reply there.
   *
   * Returns the message ID of the posted reply, or undefined if no client
   * is connected (synthetic-signal-only mode).
   */
  async replyInThread(
    channelId: string,
    messageId: string,
    content: string,
    threadName: string,
  ): Promise<string | undefined> {
    if (!this.client) {
      console.log('[discord-client] replyInThread skipped — no client connected');
      return undefined;
    }

    const channel = await this.client.channels.fetch(channelId);
    if (!channel) {
      throw new Error(`Channel ${channelId} not found`);
    }

    // If the channel IS a thread already, just send into it
    if (channel.isThread()) {
      const sent = await (channel as ThreadChannel).send(content);
      return sent.id;
    }

    // Top-level channel: fetch the message, create a thread from it, post
    if (!('messages' in channel)) {
      throw new Error(`Channel ${channelId} does not support messages`);
    }
    const textChannel = channel as TextChannel;
    const message = await textChannel.messages.fetch(messageId);

    // Check if the message already has a thread
    if (message.thread) {
      const sent = await message.thread.send(content);
      return sent.id;
    }

    // Create a new thread from the message
    const thread = await message.startThread({
      name: threadName,
      autoArchiveDuration: 60,  // auto-archive after 1 hour of inactivity
    });
    const sent = await thread.send(content);
    return sent.id;
  }
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DiscordClient } from './discord-client.js';
import type { DiscordAdapter } from './discord-adapter.js';
import type { Client, ClientOptions, Message, TextChannel, ThreadChannel } from 'discord.js';

function makeMockAdapter(): DiscordAdapter {
  return { handleEvent: vi.fn() } as unknown as DiscordAdapter;
}

function makeMockThread(sentMessageId: string): ThreadChannel {
  return {
    send: vi.fn().mockResolvedValue({ id: sentMessageId }),
    isThread: () => true,
  } as unknown as ThreadChannel;
}

function makeMockMessage(opts: {
  thread?: ThreadChannel | null;
  sentMessageId: string;
}): Message {
  return {
    thread: opts.thread ?? null,
    startThread: vi.fn().mockResolvedValue({
      send: vi.fn().mockResolvedValue({ id: opts.sentMessageId }),
    }),
  } as unknown as Message;
}

function makeMockTextChannel(opts: {
  message: Message;
  isThread?: boolean;
}): TextChannel {
  return {
    isThread: () => opts.isThread ?? false,
    messages: { fetch: vi.fn().mockResolvedValue(opts.message) },
    send: vi.fn(),
  } as unknown as TextChannel;
}

describe('DiscordClient.replyInThread', () => {
  let adapter: DiscordAdapter;

  beforeEach(() => {
    adapter = makeMockAdapter();
  });

  it('creates a new thread and posts reply for top-level message', async () => {
    const message = makeMockMessage({ thread: null, sentMessageId: 'reply-1' });
    const channel = makeMockTextChannel({ message });

    const mockDiscordClient = {
      channels: { fetch: vi.fn().mockResolvedValue(channel) },
      on: vi.fn(),
      login: vi.fn().mockResolvedValue('token'),
      destroy: vi.fn(),
    } as unknown as Client;

    const client = new DiscordClient({
      adapter,
      token: 'test-token',
      clientFactory: (_opts: ClientOptions) => mockDiscordClient,
    });
    await client.start();

    const replyId = await client.replyInThread('chan-1', 'msg-1', 'Hello!', 'Arnold responding');

    expect(replyId).toBe('reply-1');
    expect(message.startThread).toHaveBeenCalledWith({
      name: 'Arnold responding',
      autoArchiveDuration: 60,
    });
  });

  it('posts in existing thread when message already has one', async () => {
    const existingThread = makeMockThread('reply-2');
    const message = makeMockMessage({ thread: existingThread, sentMessageId: 'reply-2' });
    const channel = makeMockTextChannel({ message });

    const mockDiscordClient = {
      channels: { fetch: vi.fn().mockResolvedValue(channel) },
      on: vi.fn(),
      login: vi.fn().mockResolvedValue('token'),
      destroy: vi.fn(),
    } as unknown as Client;

    const client = new DiscordClient({
      adapter,
      token: 'test-token',
      clientFactory: (_opts: ClientOptions) => mockDiscordClient,
    });
    await client.start();

    const replyId = await client.replyInThread('chan-1', 'msg-1', 'Hello!', 'Arnold responding');

    expect(replyId).toBe('reply-2');
    expect(existingThread.send).toHaveBeenCalledWith('Hello!');
    expect(message.startThread).not.toHaveBeenCalled();
  });

  it('posts directly when channel is already a thread', async () => {
    const threadChannel = makeMockThread('reply-3');

    const mockDiscordClient = {
      channels: { fetch: vi.fn().mockResolvedValue(threadChannel) },
      on: vi.fn(),
      login: vi.fn().mockResolvedValue('token'),
      destroy: vi.fn(),
    } as unknown as Client;

    const client = new DiscordClient({
      adapter,
      token: 'test-token',
      clientFactory: (_opts: ClientOptions) => mockDiscordClient,
    });
    await client.start();

    const replyId = await client.replyInThread('thread-chan', 'msg-1', 'Hi!', 'Arnold responding');

    expect(replyId).toBe('reply-3');
    expect(threadChannel.send).toHaveBeenCalledWith('Hi!');
  });

  it('returns undefined when no client is connected', async () => {
    const client = new DiscordClient({ adapter });
    // Don't call start() — no token set

    const replyId = await client.replyInThread('chan-1', 'msg-1', 'Hi!', 'Arnold responding');

    expect(replyId).toBeUndefined();
  });
});
pnpm turbo test --filter=@cambium/integrations -- --run discord-client
feat(integrations): add DiscordClient.replyInThread for threaded replies

Creates a thread from the original message and posts the persona's
reply there. Handles existing threads and thread-channel messages.

Task 6: Error Receptor

Goal: Register a receptor that catches cortex.invocation_failed signals and writes rows to the invocation_errors table via Drizzle.

Files: - Create: packages/cortex/src/error-receptor.ts - Create: packages/cortex/src/error-receptor.test.ts

Implementation

import type { Receptor, ReceptorId, Signal } from '@cambium/core';
import { createId } from '@cambium/core';

export interface InvocationErrorRecord {
  id: string;
  workspace_id: string;
  persona: string;
  signal_id: string;
  stage: string;
  error_message: string;
  stack_trace: string | null;
  created_at: Date;
  resolved_at: Date | null;
  resolution: string | null;
}

export interface InvocationErrorStore {
  insert(record: InvocationErrorRecord): Promise<void>;
}

export interface CreateErrorReceptorOptions {
  id: ReceptorId;
  store: InvocationErrorStore;
  idGenerator: () => string;
}

/**
 * Creates a receptor that listens for cortex.invocation_failed signals
 * and writes each failure to the invocation_errors table.
 */
export function createInvocationErrorReceptor(
  opts: CreateErrorReceptorOptions,
): Receptor {
  return {
    id: opts.id,
    owner: 'cortex-error-receptor',
    binds_to: ['cortex.invocation_failed'],
    response: async (signal: Signal) => {
      const payload = signal.payload;
      const record: InvocationErrorRecord = {
        id: opts.idGenerator(),
        workspace_id: signal.workspace_id,
        persona: typeof payload['persona'] === 'string' ? payload['persona'] : 'unknown',
        signal_id: typeof payload['signal_id'] === 'string' ? payload['signal_id'] : signal.id,
        stage: typeof payload['stage'] === 'string' ? payload['stage'] : 'unknown',
        error_message: typeof payload['error_message'] === 'string' ? payload['error_message'] : 'Unknown error',
        stack_trace: typeof payload['stack_trace'] === 'string' ? payload['stack_trace'] : null,
        created_at: new Date(),
        resolved_at: null,
        resolution: null,
      };

      await opts.store.insert(record);
    },
  };
}
import { describe, it, expect, vi } from 'vitest';
import { createInvocationErrorReceptor, type InvocationErrorStore, type InvocationErrorRecord } from './error-receptor.js';
import type { Signal, SignalId, ReceptorId } from '@cambium/core';
import { createId } from '@cambium/core';

function makeFailureSignal(overrides: Partial<Record<string, unknown>> = {}): Signal {
  return {
    id: createId<SignalId>('test-failure-signal'),
    type: 'cortex.invocation_failed',
    class: 'stress',
    source_layer: 'cortex',
    source_id: 'persona-router',
    workspace_id: 'obadiah',
    intensity: 1.0,
    decay_rate: 0,
    cascade_potential: false,
    payload: {
      persona: 'arnold',
      signal_id: 'original-signal-id',
      stage: 'generate',
      error_message: 'Anthropic API error 500: Internal Server Error',
      stack_trace: 'Error: Anthropic API error 500\n  at fetch...',
      ...overrides,
    },
    affinity: ['cortex', 'error'],
    emitted_at: new Date().toISOString(),
  } as Signal;
}

class MockErrorStore implements InvocationErrorStore {
  records: InvocationErrorRecord[] = [];
  async insert(record: InvocationErrorRecord): Promise<void> {
    this.records.push(record);
  }
}

describe('createInvocationErrorReceptor', () => {
  it('writes an error record when invocation_failed signal arrives', async () => {
    const store = new MockErrorStore();
    const receptor = createInvocationErrorReceptor({
      id: createId<ReceptorId>('test-error-receptor'),
      store,
      idGenerator: () => 'generated-id-1',
    });

    expect(receptor.binds_to).toEqual(['cortex.invocation_failed']);

    await receptor.response(makeFailureSignal());

    expect(store.records).toHaveLength(1);
    const record = store.records[0]!;
    expect(record.id).toBe('generated-id-1');
    expect(record.workspace_id).toBe('obadiah');
    expect(record.persona).toBe('arnold');
    expect(record.signal_id).toBe('original-signal-id');
    expect(record.stage).toBe('generate');
    expect(record.error_message).toBe('Anthropic API error 500: Internal Server Error');
    expect(record.stack_trace).toContain('Anthropic API error 500');
    expect(record.resolved_at).toBeNull();
    expect(record.resolution).toBeNull();
  });

  it('handles missing payload fields gracefully', async () => {
    const store = new MockErrorStore();
    const receptor = createInvocationErrorReceptor({
      id: createId<ReceptorId>('test-error-receptor'),
      store,
      idGenerator: () => 'generated-id-2',
    });

    await receptor.response(makeFailureSignal({
      persona: undefined,
      stage: undefined,
      error_message: undefined,
      stack_trace: undefined,
    }));

    expect(store.records).toHaveLength(1);
    const record = store.records[0]!;
    expect(record.persona).toBe('unknown');
    expect(record.stage).toBe('unknown');
    expect(record.error_message).toBe('Unknown error');
    expect(record.stack_trace).toBeNull();
  });
});
pnpm turbo test --filter=@cambium/cortex -- --run error-receptor
feat(cortex): add error receptor for invocation failure tracking

Catches cortex.invocation_failed signals and writes to the
invocation_errors table for composting feedback.

Task 7: Provider Boot

Goal: Register three Anthropic providers (haiku/sonnet/opus) in start-rhythms.ts using the real ProviderRegistry API.

The ProviderRegistry stores providers via a ProviderStore. Currently start-rhythms.ts does not use providers at all. We need to instantiate an InMemoryProviderStore (v1 – no Pg provider store is used yet in production), create a ProviderRegistry, register three providers with different model configs and capabilities, create auth profiles, and instantiate an AuthenticatedLlmCaller wired to the registry.

The AuthenticatedLlmCaller.call() method takes (provider, profile, credential, prompt, options) and handles the raw HTTP call via the Anthropic API format. We also need to create LlmCaller-compatible wrappers that persona-tiebreaker and persona-invoker can use.

Files: - Modify: deployments/obadiah/bridge/start-rhythms.ts

Implementation

import {
  ProviderRegistry,
  InMemoryProviderStore,
  InMemoryAuthProfileStore,
  AuthenticatedLlmCaller,
  PROVIDER_TEMPLATES,
} from '@cambium/providers';
import type { LlmCaller } from '@cambium/cortex';
  // ── Provider organ (LLM access) ─────────────────────────────────
  // Three Anthropic providers: haiku (triage), sonnet (general), opus (complex).
  // Gated on ANTHROPIC_API_KEY — without it, persona invocations are skipped.
  const anthropicKey = process.env['ANTHROPIC_API_KEY'];
  let haikuCaller: LlmCaller | undefined;
  let sonnetCaller: LlmCaller | undefined;

  if (anthropicKey) {
    const providerStore = new InMemoryProviderStore();
    const authProfileStore = new InMemoryAuthProfileStore();
    const providerRegistry = new ProviderRegistry({ store: providerStore });
    const anthropicTemplate = PROVIDER_TEMPLATES['anthropic']!;

    const haikuProvider = await providerRegistry.register({
      workspace_id: workspace,
      name: 'Anthropic Haiku (triage)',
      kind: 'llm',
      endpoint: anthropicTemplate.endpoint,
      auth: { type: 'api_key' },
      capabilities: ['triage', 'classification', 'tiebreaker'],
      cost_per_call: 0.00025,
      config: {
        default_model: 'claude-haiku-4-20250514',
        api_format: 'anthropic',
        auth_header: anthropicTemplate.auth_header,
      },
    });

    const sonnetProvider = await providerRegistry.register({
      workspace_id: workspace,
      name: 'Anthropic Sonnet (general)',
      kind: 'llm',
      endpoint: anthropicTemplate.endpoint,
      auth: { type: 'api_key' },
      capabilities: ['text_generation', 'reasoning', 'persona_response'],
      cost_per_call: 0.003,
      config: {
        default_model: 'claude-sonnet-4-20250514',
        api_format: 'anthropic',
        auth_header: anthropicTemplate.auth_header,
      },
    });

    const opusProvider = await providerRegistry.register({
      workspace_id: workspace,
      name: 'Anthropic Opus (complex)',
      kind: 'llm',
      endpoint: anthropicTemplate.endpoint,
      auth: { type: 'api_key' },
      capabilities: ['deep_reasoning', 'complex_analysis', 'intent_decomposition'],
      cost_per_call: 0.015,
      config: {
        default_model: 'claude-opus-4-20250514',
        api_format: 'anthropic',
        auth_header: anthropicTemplate.auth_header,
      },
    });

    // Auth profiles — all three providers share the same API key
    for (const provider of [haikuProvider, sonnetProvider, opusProvider]) {
      await authProfileStore.save({
        id: `anthropic-key-${provider.id}` as ReturnType<typeof createId>,
        provider_id: provider.id,
        workspace_id: provider.workspace_id,
        name: `API Key for ${provider.name}`,
        mode: 'api_key',
        credential_env_var: 'ANTHROPIC_API_KEY',
        status: 'active',
        usage_stats: { total_requests: 0, error_count: 0, consecutive_failures: 0 },
        is_last_good: true,
        created_at: new Date().toISOString(),
        updated_at: new Date().toISOString(),
      });
    }

    const llmCaller = new AuthenticatedLlmCaller({
      providerRegistry,
      authProfileStore,
    });

    // Create LlmCaller-compatible wrappers for tiebreaker (haiku) and invoker (sonnet)
    haikuCaller = {
      call: async (prompt: string): Promise<string> => {
        const auth = await llmCaller.findAuthenticatedProvider(workspace, 'tiebreaker');
        if (!auth) throw new Error('No authenticated haiku provider available');
        const result = await llmCaller.call(auth.provider, auth.profile, auth.credential, prompt, {
          model: 'claude-haiku-4-20250514',
          maxTokens: 100,
          temperature: 0.0,
          budgetScopes: { workspaceId: workspace },
        });
        return result.content;
      },
    };

    sonnetCaller = {
      call: async (prompt: string): Promise<string> => {
        const auth = await llmCaller.findAuthenticatedProvider(workspace, 'persona_response');
        if (!auth) throw new Error('No authenticated sonnet provider available');
        const result = await llmCaller.call(auth.provider, auth.profile, auth.credential, prompt, {
          model: 'claude-sonnet-4-20250514',
          maxTokens: 1024,
          temperature: 0.7,
          budgetScopes: { workspaceId: workspace },
        });
        return result.content;
      },
    };

    void opusProvider;  // registered but not wired for v1

    console.log('[providers] registered 3 Anthropic providers (haiku/sonnet/opus)');
  } else {
    console.log(
      '[providers] ANTHROPIC_API_KEY not set; persona invocations will be skipped (routing + memory marks still active)',
    );
  }
pnpm turbo typecheck --filter=./deployments/obadiah/...
feat(brain): register Anthropic providers gated on ANTHROPIC_API_KEY

Three providers (haiku/sonnet/opus) with LlmCaller wrappers for
tiebreaker and invoker. Graceful no-op without the key.

Task 8: Wire Tiebreaker into Persona Router

Goal: Modify createPersonaRouter to accept an optional tiebreaker caller and persona profile loader config. When multiple personas match a single signal, call the tiebreaker to select one winner. Preserve all memory marks (existing behavior).

Files: - Modify: packages/cortex/src/persona-router.ts

Implementation

The persona router currently registers one receptor per persona. Each receptor independently checks its triggers and emits memory marks. The “no-match tracker” uses a Set to detect unmatched signals.

For tiebreaker support, the architecture shifts: instead of each persona receptor acting independently, we need a coordinating receptor that collects all matches for a signal, runs the tiebreaker if N > 1, and then invokes the winner. This is best done by adding a second phase that runs after all persona receptors have fired.

import type { LlmCaller } from './intent-decomposer.js';
import { runTiebreaker } from './persona-tiebreaker.js';

// Add to CreatePersonaRouterOptions:
export interface CreatePersonaRouterOptions {
  // ... existing fields ...

  /**
   * Optional LLM caller for tiebreaking multi-match signals. When provided
   * and multiple personas match, one Haiku call picks the winner. When
   * omitted, the first matched persona wins (alphabetical by key).
   */
  tiebreakerCaller?: LlmCaller;

  /**
   * Called after tiebreaker resolution with the winning persona and signal.
   * This is where PersonaInvoker gets wired in Task 9.
   */
  onPersonaSelected?: (winnerKey: string, signal: Signal) => Promise<void>;
}

In place of tracking only matchedSignalIds (a Set of strings), change it to matchedSignalPersonas (a Map from signal ID to array of matched persona keys):

  const matchedSignalPersonas = new Map<string, ParsedPersona[]>();

In each persona receptor’s response callback, replace matchedSignalIds.add(signal.id) with:

        const existing = matchedSignalPersonas.get(signal.id) ?? [];
        existing.push(persona);
        matchedSignalPersonas.set(signal.id, existing);

Replace the tracker receptor’s queueMicrotask body:

    response: (signal: Signal) => {
      queueMicrotask(async () => {
        const matched = matchedSignalPersonas.get(signal.id);
        matchedSignalPersonas.delete(signal.id);

        if (!matched || matched.length === 0) {
          log.info(`[router] no persona matched for ${signal.type} ${signal.id}`);
          return;
        }

        let winnerKey: string;
        if (matched.length === 1) {
          winnerKey = matched[0]!.key;
        } else if (options.tiebreakerCaller) {
          try {
            const candidates = matched.map((p) => ({
              key: p.key,
              label: p.label,
              role: p.role,
              description: p.role,  // use role as short description
            }));
            const content = extractContent(signal) ?? '';
            const result = await runTiebreaker(candidates, content, options.tiebreakerCaller);
            winnerKey = result.winnerKey;
            log.info(
              `[router] tiebreaker picked ${winnerKey} from [${result.allCandidateKeys.join(', ')}] for ${signal.id}`,
            );
          } catch (err) {
            log.error(
              `[router] tiebreaker failed for ${signal.id}: ${err instanceof Error ? err.message : String(err)}`,
            );
            winnerKey = matched[0]!.key;
          }
        } else {
          winnerKey = matched[0]!.key;
        }

        if (options.onPersonaSelected) {
          try {
            await options.onPersonaSelected(winnerKey, signal);
          } catch (err) {
            log.error(
              `[router] onPersonaSelected failed for ${winnerKey} on ${signal.id}: ${err instanceof Error ? err.message : String(err)}`,
            );
          }
        }
      });
    },
pnpm turbo test --filter=@cambium/cortex -- --run persona-router
feat(cortex): wire LLM tiebreaker into persona router

When multiple personas match a signal and a tiebreakerCaller is
provided, one Haiku call picks the winner. All memory marks are
still emitted for every match (existing behavior preserved).

Task 9: Wire PersonaInvoker into Persona Router

Goal: Wire the PersonaInvoker into the persona router’s onPersonaSelected callback. When a persona wins, load its profile, call recall() for context, invoke the persona, post the reply via DiscordClient.replyInThread, and emit a persona.responded signal. Wrap in try/catch that emits cortex.invocation_failed on error.

Files: - Modify: deployments/obadiah/bridge/start-rhythms.ts

Implementation

import {
  createPersonaRouter,
  type LlmCaller,
  invokePersona,
  loadPersonaProfile,
  createInvocationErrorReceptor,
} from '@cambium/cortex';
import { recall } from '@cambium/memory';
import type { WorkspaceId } from '@cambium/core';

Note: Some of these imports overlap with Task 7. The final file should have a single, merged import block.

  // ── Persona router organ ─────────────────────────────────────
  const personaProfilesDir = '/Users/joshua/Projects/Obadiah/personas';

  await createPersonaRouter({
    personasPath: spore.paths.personas,
    signalBus: bus,
    logger: console,
    signalEmitter: bus.createAndEmit.bind(bus),
    tiebreakerCaller: haikuCaller,
    onPersonaSelected: sonnetCaller
      ? async (winnerKey: string, signal) => {
          const channelId = typeof signal.payload['channel_id'] === 'string'
            ? signal.payload['channel_id']
            : undefined;
          const messageId = typeof signal.payload['message_id'] === 'string'
            ? signal.payload['message_id']
            : undefined;

          if (!channelId || !messageId) {
            console.log(`[invoker] skipping ${winnerKey} — no channel_id or message_id in signal ${signal.id}`);
            return;
          }

          try {
            // Load persona profile
            const profile = await loadPersonaProfile({
              profilesDir: personaProfilesDir,
              yamlDescription: `Persona ${winnerKey}`,
              personaKey: winnerKey,
              label: winnerKey.charAt(0).toUpperCase() + winnerKey.slice(1),
              role: winnerKey,
            });

            // Recall memory context
            const recallResult = await recall(
              {
                about: `joshua.discord.routed.${winnerKey}`,
                horizon: '7d',
                workspace_id: workspace as WorkspaceId,
              },
              {
                store: memoryStore,
                substrate,
                daily,
                compost,
                compostTemplateIds: [],
                dailyDates: [new Date().toISOString().slice(0, 10)],
              },
            );

            // Invoke persona
            const result = await invokePersona(
              { persona: profile, signal, recallResult },
              { llmCaller: sonnetCaller! },
            );

            // Post threaded reply
            const threadName = `${profile.label} responding`;
            await discordClient.replyInThread(channelId, messageId, result.responseText, threadName);

            // Emit persona.responded signal
            await bus.createAndEmit({
              type: 'persona.responded',
              class: 'coordination',
              source_layer: 'cortex',
              source_id: `persona-invoker-${winnerKey}`,
              workspace_id: workspace,
              intensity: 0.6,
              decay_rate: 0.1,
              payload: {
                persona: winnerKey,
                channel_id: channelId,
                message_id: messageId,
                response_length: result.responseText.length,
                prompt_token_estimate: result.promptTokenEstimate,
              },
              affinity: ['cortex', 'persona', winnerKey],
            });

            console.log(
              `[invoker] ${winnerKey} responded in thread (${result.responseText.length} chars) for signal ${signal.id}`,
            );
          } catch (err) {
            const errorMessage = err instanceof Error ? err.message : String(err);
            const stackTrace = err instanceof Error ? err.stack ?? null : null;
            console.error(`[invoker] ${winnerKey} invocation failed for signal ${signal.id}: ${errorMessage}`);

            try {
              await bus.createAndEmit({
                type: 'cortex.invocation_failed',
                class: 'stress',
                source_layer: 'cortex',
                source_id: 'persona-invoker',
                workspace_id: workspace,
                intensity: 1.0,
                decay_rate: 0,
                payload: {
                  persona: winnerKey,
                  signal_id: signal.id,
                  stage: 'generate',
                  error_message: errorMessage,
                  stack_trace: stackTrace,
                },
                affinity: ['cortex', 'error'],
              });
            } catch (emitErr) {
              console.error('[invoker] failed to emit invocation_failed signal:', emitErr);
            }
          }
        }
      : undefined,
  });
  // ── Cortex error receptor ───────────────────────────────────
  // Writes cortex.invocation_failed signals to the invocation_errors table.
  // Uses a DrizzleInvocationErrorStore that wraps the db connection.
  // For v1, we use a simple inline store that writes via raw drizzle insert.
  const invocationErrorStore = {
    async insert(record: {
      id: string;
      workspace_id: string;
      persona: string;
      signal_id: string;
      stage: string;
      error_message: string;
      stack_trace: string | null;
      created_at: Date;
      resolved_at: Date | null;
      resolution: string | null;
    }): Promise<void> {
      const { invocationErrors } = await import('@cambium/db/schema');
      await db.insert(invocationErrors).values({
        id: record.id,
        workspace_id: record.workspace_id,
        persona: record.persona,
        signal_id: record.signal_id,
        stage: record.stage,
        error_message: record.error_message,
        stack_trace: record.stack_trace,
        created_at: record.created_at,
        resolved_at: record.resolved_at,
        resolution: record.resolution,
      });
    },
  };

  bus.registerReceptor(
    createInvocationErrorReceptor({
      id: createId<ReceptorId>(`${workspace}-cortex-error`),
      store: invocationErrorStore,
      idGenerator: () => crypto.randomUUID(),
    }),
  );
  console.log('[cortex] error receptor registered (cortex.invocation_failed → invocation_errors)');
pnpm turbo typecheck
feat(brain): wire PersonaInvoker + error receptor into brain boot

Winning persona loads profile, recalls memory context, generates
a Sonnet response, and posts it as a threaded Discord reply.
Failures emit cortex.invocation_failed and write to invocation_errors.

Task 10: Graceful No-Op Without ANTHROPIC_API_KEY

Goal: Verify the graceful degradation: without ANTHROPIC_API_KEY, persona routing and memory marks still work, but invocation (tiebreaker + response generation) is skipped.

This is already handled by Task 7’s guard (if (anthropicKey)) which leaves haikuCaller and sonnetCaller as undefined, and Task 9’s guard (sonnetCaller ? ... : undefined) which leaves onPersonaSelected as undefined. The persona router passes tiebreakerCaller: undefined which means multi-match defaults to first-match (existing v1 behavior).

Files: - No new files. This task verifies the existing guards.

Steps

ANTHROPIC_API_KEY= npx tsx deployments/obadiah/bridge/start-rhythms.ts 2>&1 | head -20

Expected: [providers] ANTHROPIC_API_KEY not set; persona invocations will be skipped appears in logs. Persona router still boots. Memory marks are still captured.

test(brain): verify graceful no-op without ANTHROPIC_API_KEY

Confirmed: routing + memory marks active, invocations skipped,
no crash.

Task 11: Integration Test

Goal: End-to-end test: emit a synthetic discord.message_created signal, mock the LLM to return a fixed response, verify the reply is posted via a mock DiscordClient, persona.responded signal is emitted, and no invocation_errors rows exist.

Files: - Create: tests/integration/cortex-invocation-e2e.test.ts

Implementation

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SignalBus } from '@cambium/signals';
import type { Signal, ReceptorId } from '@cambium/core';
import { createId } from '@cambium/core';
import { createPersonaRouter } from '@cambium/cortex';
import { createInvocationErrorReceptor, type InvocationErrorRecord, type InvocationErrorStore } from '@cambium/cortex';
import { invokePersona, type InvokerDeps } from '@cambium/cortex';
import { loadPersonaProfile } from '@cambium/cortex';
import type { LlmCaller } from '@cambium/cortex';
import { writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

// ── Mock stores ──────────────────────────────────────────────────

class InMemorySignalPersister {
  signals: Array<Record<string, unknown>> = [];
  async persist(signal: Record<string, unknown>): Promise<void> {
    this.signals.push(signal);
  }
}

class MockErrorStore implements InvocationErrorStore {
  records: InvocationErrorRecord[] = [];
  async insert(record: InvocationErrorRecord): Promise<void> {
    this.records.push(record);
  }
}

// ── Mock Discord client ──────────────────────────────────────────

interface ReplyCall {
  channelId: string;
  messageId: string;
  content: string;
  threadName: string;
}

class MockDiscordReplyCapture {
  calls: ReplyCall[] = [];

  async replyInThread(
    channelId: string,
    messageId: string,
    content: string,
    threadName: string,
  ): Promise<string> {
    this.calls.push({ channelId, messageId, content, threadName });
    return 'mock-reply-id';
  }
}

describe('Cortex invocation end-to-end', () => {
  let tmpPersonaDir: string;

  beforeEach(async () => {
    tmpPersonaDir = join(tmpdir(), `e2e-personas-${Date.now()}`);
    await mkdir(tmpPersonaDir, { recursive: true });
    await writeFile(
      join(tmpPersonaDir, 'arnold.md'),
      '# Arnold\n\n*"Stop being a baby."*\n\n## Voice\nTough but motivating.\n',
    );
  });

  it('routes message to Arnold, generates response, posts thread reply', async () => {
    // -- Setup signal bus --
    const persister = new InMemorySignalPersister();
    const bus = new SignalBus({ persister: persister as never, maxRetries: 0, retryDelayMs: 0 });

    // -- Track emitted signals --
    const emittedSignals: Signal[] = [];
    bus.registerReceptor({
      id: createId<ReceptorId>('signal-spy'),
      owner: 'test',
      binds_to: ['persona.responded', 'cortex.invocation_failed'],
      response: (signal: Signal) => { emittedSignals.push(signal); },
    });

    // -- Error receptor --
    const errorStore = new MockErrorStore();
    bus.registerReceptor(
      createInvocationErrorReceptor({
        id: createId<ReceptorId>('test-error-receptor'),
        store: errorStore,
        idGenerator: () => crypto.randomUUID(),
      }),
    );

    // -- Mock LLM --
    const mockSonnetResponse = "Come on, you know what to do. Get to bed by 10pm tonight, no screens an hour before. Your body is a machine — give it the rest it needs. No excuses.";
    const mockHaikuResponse = 'arnold';
    const mockLlm: LlmCaller = {
      call: vi.fn().mockResolvedValue(mockSonnetResponse),
    };
    const mockTiebreakerLlm: LlmCaller = {
      call: vi.fn().mockResolvedValue(mockHaikuResponse),
    };

    // -- Mock Discord reply --
    const discordReply = new MockDiscordReplyCapture();

    // -- Write a temporary agents.yaml --
    const agentsYaml = join(tmpPersonaDir, 'agents.yaml');
    await writeFile(agentsYaml, `agents:
  arnold:
    role: health-fitness
    label: Arnold
    description: "Health, fitness, wellness advisor"
    domain_triggers:
      - health
      - fitness
      - sleep
`);

    // -- Boot persona router --
    await createPersonaRouter({
      personasPath: agentsYaml,
      signalBus: bus,
      logger: console,
      signalEmitter: bus.createAndEmit.bind(bus),
      tiebreakerCaller: mockTiebreakerLlm,
      onPersonaSelected: async (winnerKey: string, signal: Signal) => {
        const channelId = typeof signal.payload['channel_id'] === 'string'
          ? signal.payload['channel_id'] : 'test-channel';
        const messageId = typeof signal.payload['message_id'] === 'string'
          ? signal.payload['message_id'] : 'test-msg';

        const profile = await loadPersonaProfile({
          profilesDir: tmpPersonaDir,
          yamlDescription: 'Health advisor',
          personaKey: winnerKey,
          label: 'Arnold',
          role: 'health-fitness',
        });

        const emptyRecall = {
          substrate: [],
          marks: [],
          daily: [],
          compost: [],
          summary: null as null,
        };

        const result = await invokePersona(
          { persona: profile, signal, recallResult: emptyRecall },
          { llmCaller: mockLlm },
        );

        await discordReply.replyInThread(channelId, messageId, result.responseText, `${profile.label} responding`);

        await bus.createAndEmit({
          type: 'persona.responded',
          class: 'coordination',
          source_layer: 'cortex',
          source_id: `persona-invoker-${winnerKey}`,
          workspace_id: signal.workspace_id,
          intensity: 0.6,
          decay_rate: 0.1,
          payload: {
            persona: winnerKey,
            channel_id: channelId,
            message_id: messageId,
            response_length: result.responseText.length,
          },
          affinity: ['cortex', 'persona', winnerKey],
        });
      },
    });

    // -- Emit synthetic Discord signal --
    await bus.createAndEmit({
      type: 'discord.message_created',
      class: 'coordination',
      source_layer: 'discord',
      source_id: 'discord:test-guild',
      workspace_id: 'obadiah',
      intensity: 0.5,
      decay_rate: 0.1,
      payload: {
        content: 'I slept terribly last night',
        channel_id: 'test-channel-123',
        message_id: 'test-message-456',
        author_username: 'joshua',
      },
      affinity: ['discord', 'message_created'],
    });

    // Wait for microtask-deferred tiebreaker + invocation to complete
    await new Promise((resolve) => setTimeout(resolve, 200));

    // -- Assertions --

    // 1. Discord reply was posted
    expect(discordReply.calls).toHaveLength(1);
    const reply = discordReply.calls[0]!;
    expect(reply.content).toContain('no screens an hour before');
    expect(reply.threadName).toBe('Arnold responding');
    expect(reply.channelId).toBe('test-channel-123');
    expect(reply.messageId).toBe('test-message-456');

    // 2. persona.responded signal was emitted
    const respondedSignals = emittedSignals.filter((s) => s.type === 'persona.responded');
    expect(respondedSignals).toHaveLength(1);
    expect(respondedSignals[0]!.payload['persona']).toBe('arnold');

    // 3. No invocation errors
    expect(errorStore.records).toHaveLength(0);
    const failedSignals = emittedSignals.filter((s) => s.type === 'cortex.invocation_failed');
    expect(failedSignals).toHaveLength(0);

    // 4. LLM was called with persona context
    expect(mockLlm.call).toHaveBeenCalledOnce();
    const prompt = (mockLlm.call as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
    expect(prompt).toContain('Arnold');
    expect(prompt).toContain('I slept terribly last night');

    // Cleanup
    await rm(tmpPersonaDir, { recursive: true, force: true });
  });
});
pnpm turbo test --filter=./tests/... -- --run cortex-invocation-e2e

If no tests config exists at repo root, run directly:

cd /Users/joshua/Projects/Unicorn.Land/cambium && npx vitest run tests/integration/cortex-invocation-e2e.test.ts
test: add cortex invocation end-to-end integration test

Synthetic Discord signal → mock LLM → threaded reply via mock
DiscordClient → persona.responded signal emitted → no errors.

Task 12: Full Monorepo Gate

Goal: Ensure the entire monorepo passes typecheck, build, and test.

Steps

export {
  loadPersonaProfile,
  type PersonaProfile,
  type LoadPersonaProfileOptions,
} from './persona-profile-loader.js';

export {
  runTiebreaker,
  type TiebreakerCandidate,
  type TiebreakerResult,
} from './persona-tiebreaker.js';

export {
  invokePersona,
  type InvokerDeps,
  type InvokerInput,
  type InvokerOutput,
} from './persona-invoker.js';

export {
  createInvocationErrorReceptor,
  type InvocationErrorStore,
  type InvocationErrorRecord,
  type CreateErrorReceptorOptions,
} from './error-receptor.js';
pnpm turbo typecheck
pnpm turbo build
pnpm turbo test
feat(cortex): export new invocation modules from barrel

Adds persona-profile-loader, persona-tiebreaker, persona-invoker,
and error-receptor to @cambium/cortex public API.

Dependency Graph

Task 1 (DB migration)          — standalone
Task 2 (Profile loader)        — standalone
Task 3 (Tiebreaker)            — standalone, uses LlmCaller interface from intent-decomposer
Task 4 (PersonaInvoker)        — depends on Task 2 (PersonaProfile type)
Task 5 (Discord replyInThread) — standalone
Task 6 (Error receptor)        — depends on Task 1 (table schema, conceptually)
Task 7 (Provider boot)         — standalone, uses @cambium/providers API
Task 8 (Wire tiebreaker)       — depends on Task 3
Task 9 (Wire invoker)          — depends on Tasks 2, 4, 5, 6, 7, 8
Task 10 (No-op verification)   — depends on Tasks 7, 9
Task 11 (Integration test)     — depends on Tasks 2, 3, 4, 6, 8
Task 12 (Monorepo gate)        — depends on all prior tasks

Parallelizable: Tasks 1, 2, 3, 5 can run in parallel.
                Tasks 4, 6 can run in parallel after Task 2 completes.
                Tasks 7, 8 can run in parallel.
                Task 9 requires 2, 4, 5, 6, 7, 8.