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
- [ ] 1.1 Add the table definition to
packages/db/src/schema.tsafter thedeadLetterstable (end of file):
// ===== 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),
]);
- [ ] 1.2 Generate the migration:
cd packages/db && pnpm drizzle-kit generate
- [ ] 1.3 Run the migration against the local database:
pnpm --filter @cambium/db migrate
- [ ] 1.4 Verify typecheck passes:
pnpm turbo typecheck --filter=@cambium/db
- [ ] 1.5 Commit:
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.
- [ ] 2.1 Create
packages/cortex/src/persona-profile-loader.ts:
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',
};
}
- [ ] 2.2 Create
packages/cortex/src/persona-profile-loader.test.ts:
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');
});
});
- [ ] 2.3 Run tests:
pnpm turbo test --filter=@cambium/cortex -- --run persona-profile-loader
- [ ] 2.4 Commit:
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>;
}
- [ ] 3.1 Create
packages/cortex/src/persona-tiebreaker.ts:
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 };
}
- [ ] 3.2 Create
packages/cortex/src/persona-tiebreaker.test.ts:
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');
});
});
- [ ] 3.3 Run tests:
pnpm turbo test --filter=@cambium/cortex -- --run persona-tiebreaker
- [ ] 3.4 Commit:
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
- [ ] 4.1 Create
packages/cortex/src/persona-invoker.ts:
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()} ===`;
}
- [ ] 4.2 Create
packages/cortex/src/persona-invoker.test.ts:
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.');
});
});
- [ ] 4.3 Run tests:
pnpm turbo test --filter=@cambium/cortex -- --run persona-invoker
- [ ] 4.4 Commit:
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
- [ ] 5.1 Add the
replyInThreadmethod to theDiscordClientclass inpackages/integrations/src/discord/discord-client.ts.
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;
}
- [ ] 5.2 Create
packages/integrations/src/discord/discord-client.test.ts:
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();
});
});
- [ ] 5.3 Run tests:
pnpm turbo test --filter=@cambium/integrations -- --run discord-client
- [ ] 5.4 Commit:
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
- [ ] 6.1 Create
packages/cortex/src/error-receptor.ts:
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);
},
};
}
- [ ] 6.2 Create
packages/cortex/src/error-receptor.test.ts:
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();
});
});
- [ ] 6.3 Run tests:
pnpm turbo test --filter=@cambium/cortex -- --run error-receptor
- [ ] 6.4 Commit:
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
- [ ] 7.1 Add provider imports to
start-rhythms.ts:
import {
ProviderRegistry,
InMemoryProviderStore,
InMemoryAuthProfileStore,
AuthenticatedLlmCaller,
PROVIDER_TEMPLATES,
} from '@cambium/providers';
import type { LlmCaller } from '@cambium/cortex';
- [ ] 7.2 Add provider boot block after the Discord client start and before the persona router section. This block is guarded by
ANTHROPIC_API_KEY:
// ── 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)',
);
}
- [ ] 7.3 Verify typecheck:
pnpm turbo typecheck --filter=./deployments/obadiah/...
- [ ] 7.4 Commit:
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.
- [ ] 8.1 Add tiebreaker-related options to
CreatePersonaRouterOptions:
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>;
}
- [ ] 8.2 Modify the no-match tracker receptor to also handle tiebreaking. Replace the
queueMicrotasklogic in the tracker receptor to collect matches and run tiebreaker:
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)}`,
);
}
}
});
},
- [ ] 8.3 Update the existing persona-router tests to ensure they still pass. The
matchedSignalIdsSet is nowmatchedSignalPersonasMap, but the external behavior (log lines, memory marks) should be identical:
pnpm turbo test --filter=@cambium/cortex -- --run persona-router
- [ ] 8.4 Commit:
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
- [ ] 9.1 Add imports to
start-rhythms.ts:
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.
- [ ] 9.2 After the provider boot block and before the persona router section, add the recall deps setup and onPersonaSelected callback. Modify the existing
createPersonaRoutercall to passtiebreakerCallerandonPersonaSelected:
// ── 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,
});
- [ ] 9.3 Register the error receptor in
start-rhythms.ts, before the persona router:
// ── 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)');
- [ ] 9.4 Verify typecheck:
pnpm turbo typecheck
- [ ] 9.5 Commit:
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
- [ ] 10.1 Run the brain without
ANTHROPIC_API_KEYand verify the log output:
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.
- [ ] 10.2 Commit:
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
- [ ] 11.1 Create
tests/integration/cortex-invocation-e2e.test.ts:
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 });
});
});
- [ ] 11.2 Run the integration test:
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
- [ ] 11.3 Commit:
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
- [ ] 12.1 Export new modules from
packages/cortex/src/index.ts:
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';
- [ ] 12.2 Run the full monorepo gate:
pnpm turbo typecheck
pnpm turbo build
pnpm turbo test
- [ ] 12.3 Fix any failures. Common issues:
- Missing
@cambium/memorytype imports in cortex — add to cortex’spackage.jsondependencies - Signal type narrowing — ensure
Signaltype used in persona-invoker matches@cambium/core‘s definition -
Drizzle schema import path —
@cambium/db/schemavs@cambium/dbdepending on package.json exports -
[ ] 12.4 Commit:
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.