Discussions

User7 min read

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

flowchart LR subgraph Single["Single Mode"] S1[User message] --> S2[1 provider streams response] end subgraph Parallel["Parallel Mode"] P1[User message] --> P2[All providers respond simultaneously] P2 --> P3[Each response saved as separate message] end subgraph Consensus["Consensus Mode"] C1[User message] --> C2[Provider A responds] C2 --> C3[Provider B reads A + responds] C3 --> C4[Provider A reads B + responds] C4 --> C5[Provider B reads A + responds] end

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:

  1. Project metadata: name, description, GitHub repo link
  2. Recent blog posts: up to 5 published posts (title + excerpt)
  3. 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:

  1. 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)
  2. Instruction injection: appends a synthetic user message with instructions to continue the discussion naturally
  3. Streaming: uses provider.stream() for real-time token delivery
  4. Abort support: checks ctx.signal?.aborted before 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?.aborted at multiple points for clean cancellation