Discussions
16. Discussion Service
The Discussion Service powers nyxCore's multi-provider LLM conversations with three distinct modes, automatic language detection, project context injection, and persona integration. It is implemented as an AsyncGenerator in src/server/services/discussion-service.ts and streamed to clients over SSE.
Three Discussion Modes
Single Mode
One provider streams a response token-by-token. The full content is accumulated and persisted as a DiscussionMessage when the done chunk arrives.
async function* streamSingleProvider(
providerName: string,
messages: LLMMessage[],
language: string,
projectContext: string | undefined,
personaPrompt: string | undefined,
ctx: DiscussionContext,
modelOverride?: string
): AsyncGenerator<DiscussionChunk>
Parallel Mode
All configured providers are called concurrently via Promise.allSettled(). Each provider uses .complete() (non-streaming) rather than .stream(). Results are yielded sequentially after all complete, each tagged with its provider name. Failed providers yield an error chunk.
const promises = providerNames.map(async (name) => {
const provider = await resolveProvider(name, ctx.tenantId);
return await provider.complete(llmMessages, completeOptions);
});
const settled = await Promise.allSettled(promises);
Consensus Mode
A multi-round roundtable discussion where each provider takes turns responding. Each provider sees the full conversation history including all previous providers' responses. The constant CONSENSUS_ROUNDS controls how many full cycles occur:
const CONSENSUS_ROUNDS = 2;
For a 2-provider, 2-round consensus: A responds, B responds to A, A responds to A+B, B responds to all -- yielding 4 total AI messages.
Each provider receives a consensus identity in its system prompt:
`You are "${myName}" in a roundtable discussion with: ${otherNames}.
Respond naturally -- react to what others said, agree or disagree, add your perspective.
Keep it focused (2-3 paragraphs). Do NOT prefix your response with your name or any brackets.`
Participant Names
Providers are assigned human-readable names instead of raw API identifiers:
const PARTICIPANT_NAMES = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
A participantMap maps each provider string to its display name. This mapping is also used in auto-continue mode.
Language Detection
The service automatically detects the user's language from the first user message and persists it on the Discussion record. Subsequent messages are constrained to respond in the same language.
const LANG_PATTERNS: [RegExp, string][] = [
[/\b(der|die|das|und|ist|ich|ein|nicht|mit|auf|fur|werden|haben)\b/i, "de"],
[/\b(le|la|les|des|est|une|avec|pour|dans|sont|nous|vous)\b/i, "fr"],
[/\b(el|la|los|las|es|una|con|para|del|por|que|mas)\b/i, "es"],
[/\b(the|and|is|are|with|for|have|this|that|from|but)\b/i, "en"],
];
The detector counts keyword matches for each language and selects the highest scorer. Ties and zero-match cases default to English.
The language instruction is appended to every system prompt:
IMPORTANT: Always respond in {LanguageName}. Do not switch languages unless the user explicitly asks you to.
Supported languages: English, German, French, Spanish (with LANG_NAMES mapping codes to full names).
Project Context Loading
When a discussion is linked to a project (discussion.projectId), loadProjectContext() assembles a context section injected into the system prompt:
- Project metadata: name, description, GitHub repo link
- Recent blog posts: up to 5 published posts (title + excerpt)
- Consolidation patterns: up to 10 patterns from the most recent completed consolidation for that project, sorted by frequency
async function loadProjectContext(projectId: string): Promise<string> {
// Assembles sections:
// ## Project: {name}
// {description}
// Repository: owner/repo
// ### Recent Posts
// - **Title**: excerpt
// ### Extracted Patterns
// - **Pattern Title** (type): description
}
Persona Integration
If a discussion has a linked Persona, its systemPrompt is prepended to the system prompt. Persona usage is tracked via incrementPersonaUsage() (fire-and-forget, non-blocking).
The system prompt is assembled by buildSystemPrompt() with the following layering:
1. Persona system prompt (if set)
2. Consensus identity instructions (if consensus mode)
3. Project context (if linked)
4. Language instruction (always)
Auto-Continue
The autoRound() function enables AI-to-AI continuation without a user message. It is triggered when ctx.auto is set to true. Behavior:
- Provider rotation: determines the next provider by finding the last assistant message's provider and advancing to the next in the
providers[]array (modular wrap-around) - Instruction injection: appends a synthetic user message with instructions to continue the discussion naturally
- Streaming: uses
provider.stream()for real-time token delivery - Abort support: checks
ctx.signal?.abortedbefore and during streaming
For multi-provider discussions, auto-continue uses consensus-style identity:
const autoInstruction = discussion.providers.length > 1
? "Continue the discussion naturally. Build on previous points, raise new angles..."
: "Continue exploring the topic. Raise new angles, go deeper...";
Message Persistence
Every AI response is saved as a DiscussionMessage with:
| Field | Source |
|---|---|
role |
"assistant" |
content |
Full accumulated text |
provider |
Provider name string |
model |
From completion result (parallel/consensus) |
tokenUsage |
{ prompt, completion, total } JSON |
costEstimate |
USD estimate from provider |
Data Model
Discussion
| Column | Type | Description |
|---|---|---|
id |
UUID |
Primary key |
tenantId |
UUID |
Tenant scope |
userId |
UUID |
Creator |
title |
String |
Discussion title |
status |
String |
active / archived / completed |
personaId |
UUID? |
Linked persona |
projectId |
UUID? |
Linked project for context |
providers |
String[] |
Provider names (e.g., ["anthropic", "openai"]) |
mode |
String |
single / parallel / consensus |
language |
String? |
Detected language code (e.g., "de", "en") |
model_override |
String? |
Specific model to use (null = provider default) |
summary |
Text? |
Exported knowledge summary |
usefulnessScore |
Float? |
0.0 to 1.0 |
exportedAt |
DateTime? |
When knowledge was exported |
DiscussionMessage
| Column | Type | Description |
|---|---|---|
id |
UUID |
Primary key |
discussionId |
UUID |
Parent discussion |
role |
String |
user / assistant / system / synthesis |
content |
Text |
Message body |
provider |
String? |
LLM provider that generated this |
model |
String? |
Specific model used |
tokenUsage |
Json? |
{ prompt, completion, total } |
costEstimate |
Float? |
Estimated cost in USD |
tRPC Router
The discussionsRouter in src/server/trpc/routers/discussions.ts exposes:
| Procedure | Type | Rate Limit | Description |
|---|---|---|---|
list |
query | standard | Paginated discussions for tenant |
get |
query | standard | Single discussion with all messages, persona, project |
create |
mutation | LLM | Create discussion + first user message |
continue |
mutation | LLM | Add a user message to existing discussion |
updatePersona |
mutation | standard | Change or remove linked persona |
archive |
mutation | standard | Archive a discussion |
exportKnowledge |
mutation | LLM | Extract knowledge as WorkflowInsight records |
byProject |
query | standard | Discussions linked to a specific project |
getExportedInsights |
query | standard | WorkflowInsights sourced from a discussion |
availableProviders |
query | standard | List all LLM providers with key status |
updateProvider |
mutation | standard | Change discussion provider(s) and model |
updateModel |
mutation | standard | Change model override |
Discussion Creation
// Input schema
{
title: string; // 1-200 chars
message: string; // 1-10000 chars (first user message)
providers: string[]; // min 1, default ["anthropic"]
mode: "single" | "parallel" | "consensus"; // default "single"
personaId?: string;
projectId?: string;
modelOverride?: string;
}
The discussion is created with the first user message embedded via a nested messages.create. LLM processing happens asynchronously via the SSE endpoint, not in the mutation -- the client gets the discussion back immediately for optimistic UI rendering.
Error Handling
Each mode handles errors at the provider level:
- Single: catches provider errors, logs them, yields an error chunk
- Parallel: uses
Promise.allSettled(), failed providers yield error messages while successful ones proceed - Consensus: per-provider try/catch within each round, allowing remaining providers to continue even if one fails
- Auto-continue: checks
ctx.signal?.abortedat multiple points for clean cancellation
