mcp-rubber-duck 1.2.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.env.desktop.example +1 -1
  2. package/.env.pi.example +1 -1
  3. package/.env.template +1 -1
  4. package/.eslintrc.json +1 -0
  5. package/CHANGELOG.md +19 -0
  6. package/README.md +238 -44
  7. package/assets/mcp-rubber-duck.png +0 -0
  8. package/audit-ci.json +2 -1
  9. package/config/config.example.json +4 -4
  10. package/dist/config/config.js +4 -4
  11. package/dist/config/config.js.map +1 -1
  12. package/dist/config/types.d.ts +78 -0
  13. package/dist/config/types.d.ts.map +1 -1
  14. package/dist/server.d.ts.map +1 -1
  15. package/dist/server.js +150 -0
  16. package/dist/server.js.map +1 -1
  17. package/dist/services/consensus.d.ts +28 -0
  18. package/dist/services/consensus.d.ts.map +1 -0
  19. package/dist/services/consensus.js +257 -0
  20. package/dist/services/consensus.js.map +1 -0
  21. package/dist/tools/duck-debate.d.ts +16 -0
  22. package/dist/tools/duck-debate.d.ts.map +1 -0
  23. package/dist/tools/duck-debate.js +272 -0
  24. package/dist/tools/duck-debate.js.map +1 -0
  25. package/dist/tools/duck-iterate.d.ts +14 -0
  26. package/dist/tools/duck-iterate.d.ts.map +1 -0
  27. package/dist/tools/duck-iterate.js +195 -0
  28. package/dist/tools/duck-iterate.js.map +1 -0
  29. package/dist/tools/duck-judge.d.ts +15 -0
  30. package/dist/tools/duck-judge.d.ts.map +1 -0
  31. package/dist/tools/duck-judge.js +208 -0
  32. package/dist/tools/duck-judge.js.map +1 -0
  33. package/dist/tools/duck-vote.d.ts +14 -0
  34. package/dist/tools/duck-vote.d.ts.map +1 -0
  35. package/dist/tools/duck-vote.js +46 -0
  36. package/dist/tools/duck-vote.js.map +1 -0
  37. package/docker-compose.yml +1 -1
  38. package/package.json +1 -1
  39. package/src/config/config.ts +4 -4
  40. package/src/config/types.ts +92 -0
  41. package/src/server.ts +154 -0
  42. package/src/services/consensus.ts +324 -0
  43. package/src/tools/duck-debate.ts +383 -0
  44. package/src/tools/duck-iterate.ts +253 -0
  45. package/src/tools/duck-judge.ts +301 -0
  46. package/src/tools/duck-vote.ts +87 -0
  47. package/tests/consensus.test.ts +282 -0
  48. package/tests/duck-debate.test.ts +286 -0
  49. package/tests/duck-iterate.test.ts +249 -0
  50. package/tests/duck-judge.test.ts +296 -0
  51. package/tests/duck-vote.test.ts +250 -0
@@ -0,0 +1,324 @@
1
+ import { VoteResult, AggregatedVote } from '../config/types.js';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ interface ParsedVote {
5
+ choice?: string;
6
+ confidence?: number | string;
7
+ reasoning?: string;
8
+ }
9
+
10
+ export class ConsensusService {
11
+ /**
12
+ * Build a voting prompt that asks the LLM to vote on options
13
+ */
14
+ buildVotePrompt(
15
+ question: string,
16
+ options: string[],
17
+ requireReasoning: boolean = true
18
+ ): string {
19
+ const optionsList = options.map((opt, i) => `${i + 1}. ${opt}`).join('\n');
20
+
21
+ const format = requireReasoning
22
+ ? `{
23
+ "choice": "<exact option text>",
24
+ "confidence": <0-100>,
25
+ "reasoning": "<brief explanation>"
26
+ }`
27
+ : `{
28
+ "choice": "<exact option text>",
29
+ "confidence": <0-100>
30
+ }`;
31
+
32
+ return `You are voting on the following question. You MUST choose exactly ONE option from the list below.
33
+
34
+ QUESTION: ${question}
35
+
36
+ OPTIONS:
37
+ ${optionsList}
38
+
39
+ INSTRUCTIONS:
40
+ 1. Analyze each option carefully
41
+ 2. Choose the BEST option based on your knowledge and reasoning
42
+ 3. Respond with ONLY a JSON object in this exact format:
43
+
44
+ ${format}
45
+
46
+ IMPORTANT:
47
+ - "choice" must be the EXACT text of one of the options above
48
+ - "confidence" must be a number from 0 to 100
49
+ - Do NOT include any text before or after the JSON
50
+ - Do NOT use markdown code blocks`;
51
+ }
52
+
53
+ /**
54
+ * Parse a vote from an LLM response
55
+ */
56
+ parseVote(
57
+ response: string,
58
+ voter: string,
59
+ nickname: string,
60
+ options: string[]
61
+ ): VoteResult {
62
+ const result: VoteResult = {
63
+ voter,
64
+ nickname,
65
+ choice: '',
66
+ confidence: 0,
67
+ reasoning: '',
68
+ rawResponse: response,
69
+ };
70
+
71
+ try {
72
+ // Try to extract JSON from the response (greedy to handle nested objects)
73
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
74
+ if (!jsonMatch) {
75
+ logger.warn(`No JSON found in vote response from ${voter}`);
76
+ return this.fallbackParse(response, voter, nickname, options);
77
+ }
78
+
79
+ const parsed = JSON.parse(jsonMatch[0]) as ParsedVote;
80
+
81
+ // Validate choice
82
+ const choice = parsed.choice?.toString().trim();
83
+ if (choice) {
84
+ // Try exact match first
85
+ const exactMatch = options.find(
86
+ opt => opt.toLowerCase() === choice.toLowerCase()
87
+ );
88
+
89
+ if (exactMatch) {
90
+ result.choice = exactMatch;
91
+ } else {
92
+ // Try partial match
93
+ const partialMatch = options.find(
94
+ opt => opt.toLowerCase().includes(choice.toLowerCase()) ||
95
+ choice.toLowerCase().includes(opt.toLowerCase())
96
+ );
97
+ if (partialMatch) {
98
+ result.choice = partialMatch;
99
+ logger.debug(`Fuzzy matched "${choice}" to "${partialMatch}" for ${voter}`);
100
+ }
101
+ }
102
+ }
103
+
104
+ // Parse confidence
105
+ if (typeof parsed.confidence === 'number') {
106
+ result.confidence = Math.max(0, Math.min(100, parsed.confidence));
107
+ } else if (typeof parsed.confidence === 'string') {
108
+ const conf = parseFloat(parsed.confidence);
109
+ if (!isNaN(conf)) {
110
+ result.confidence = Math.max(0, Math.min(100, conf));
111
+ }
112
+ }
113
+
114
+ // Parse reasoning
115
+ if (parsed.reasoning) {
116
+ result.reasoning = parsed.reasoning.toString().trim();
117
+ }
118
+
119
+ } catch (error) {
120
+ logger.warn(`Failed to parse JSON vote from ${voter}:`, error);
121
+ return this.fallbackParse(response, voter, nickname, options);
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ /**
128
+ * Fallback parsing when JSON fails - try to extract choice from text
129
+ */
130
+ private fallbackParse(
131
+ response: string,
132
+ voter: string,
133
+ nickname: string,
134
+ options: string[]
135
+ ): VoteResult {
136
+ const result: VoteResult = {
137
+ voter,
138
+ nickname,
139
+ choice: '',
140
+ confidence: 50, // Default confidence for fallback
141
+ reasoning: 'Vote extracted via fallback parsing',
142
+ rawResponse: response,
143
+ };
144
+
145
+ // Try to find any option mentioned in the response
146
+ const responseLower = response.toLowerCase();
147
+ for (const option of options) {
148
+ if (responseLower.includes(option.toLowerCase())) {
149
+ result.choice = option;
150
+ logger.debug(`Fallback parsed choice "${option}" from ${voter}`);
151
+ break;
152
+ }
153
+ }
154
+
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Aggregate votes into a final result
160
+ */
161
+ aggregateVotes(
162
+ question: string,
163
+ options: string[],
164
+ votes: VoteResult[]
165
+ ): AggregatedVote {
166
+ // Initialize tally and confidence tracking
167
+ const tally: Record<string, number> = {};
168
+ const confidenceSums: Record<string, number> = {};
169
+ const confidenceCounts: Record<string, number> = {};
170
+
171
+ for (const option of options) {
172
+ tally[option] = 0;
173
+ confidenceSums[option] = 0;
174
+ confidenceCounts[option] = 0;
175
+ }
176
+
177
+ // Count votes
178
+ let validVotes = 0;
179
+ for (const vote of votes) {
180
+ if (vote.choice && options.includes(vote.choice)) {
181
+ tally[vote.choice]++;
182
+ confidenceSums[vote.choice] += vote.confidence;
183
+ confidenceCounts[vote.choice]++;
184
+ validVotes++;
185
+ }
186
+ }
187
+
188
+ // Calculate average confidence per option
189
+ const confidenceByOption: Record<string, number> = {};
190
+ for (const option of options) {
191
+ confidenceByOption[option] = confidenceCounts[option] > 0
192
+ ? Math.round(confidenceSums[option] / confidenceCounts[option])
193
+ : 0;
194
+ }
195
+
196
+ // Determine winner
197
+ const maxVotes = Math.max(...Object.values(tally));
198
+ const winners = options.filter(opt => tally[opt] === maxVotes && maxVotes > 0);
199
+
200
+ const isTie = winners.length > 1;
201
+ let winner: string | null = null;
202
+
203
+ if (winners.length === 1) {
204
+ winner = winners[0];
205
+ } else if (isTie && winners.length > 0) {
206
+ // Break tie by confidence
207
+ let highestConfidence = -1;
208
+ for (const w of winners) {
209
+ if (confidenceByOption[w] > highestConfidence) {
210
+ highestConfidence = confidenceByOption[w];
211
+ winner = w;
212
+ }
213
+ }
214
+ }
215
+
216
+ // Determine consensus level
217
+ const consensusLevel = this.determineConsensusLevel(
218
+ validVotes,
219
+ votes.length,
220
+ maxVotes,
221
+ isTie
222
+ );
223
+
224
+ return {
225
+ question,
226
+ options,
227
+ winner,
228
+ isTie,
229
+ tally,
230
+ confidenceByOption,
231
+ votes,
232
+ totalVoters: votes.length,
233
+ validVotes,
234
+ consensusLevel,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Determine the level of consensus reached
240
+ */
241
+ private determineConsensusLevel(
242
+ validVotes: number,
243
+ totalVoters: number,
244
+ maxVotes: number,
245
+ isTie: boolean
246
+ ): 'unanimous' | 'majority' | 'plurality' | 'split' | 'none' {
247
+ if (validVotes === 0) {
248
+ return 'none';
249
+ }
250
+
251
+ const winnerRatio = maxVotes / validVotes;
252
+
253
+ if (winnerRatio === 1 && validVotes === totalVoters) {
254
+ return 'unanimous';
255
+ } else if (winnerRatio > 0.5) {
256
+ return 'majority';
257
+ } else if (!isTie && maxVotes > 0) {
258
+ return 'plurality';
259
+ } else if (isTie) {
260
+ return 'split';
261
+ }
262
+
263
+ return 'none';
264
+ }
265
+
266
+ /**
267
+ * Format the aggregated vote result for display
268
+ */
269
+ formatVoteResult(result: AggregatedVote): string {
270
+ let output = `šŸ—³ļø **Vote Results**\n`;
271
+ output += `═══════════════════════════════════════\n\n`;
272
+ output += `**Question:** ${result.question}\n\n`;
273
+
274
+ // Winner announcement
275
+ if (result.winner) {
276
+ const emoji = result.consensusLevel === 'unanimous' ? 'šŸ†' :
277
+ result.consensusLevel === 'majority' ? 'āœ…' : 'šŸ“Š';
278
+ output += `${emoji} **Winner:** ${result.winner}`;
279
+ if (result.isTie) {
280
+ output += ` (tie-breaker by confidence)`;
281
+ }
282
+ output += `\n`;
283
+ output += `šŸ“ˆ **Consensus:** ${result.consensusLevel}\n\n`;
284
+ } else {
285
+ output += `āš ļø **No valid votes recorded**\n\n`;
286
+ }
287
+
288
+ // Vote tally
289
+ output += `**Vote Tally:**\n`;
290
+ const sortedOptions = [...result.options].sort(
291
+ (a, b) => result.tally[b] - result.tally[a]
292
+ );
293
+
294
+ for (const option of sortedOptions) {
295
+ const votes = result.tally[option];
296
+ const confidence = result.confidenceByOption[option];
297
+ const bar = 'ā–ˆ'.repeat(Math.min(votes * 3, 15));
298
+ const isWinner = option === result.winner;
299
+ const marker = isWinner ? ' šŸ‘‘' : '';
300
+ output += ` ${option}: ${bar} ${votes} vote(s) (avg confidence: ${confidence}%)${marker}\n`;
301
+ }
302
+
303
+ output += `\n**Individual Votes:**\n`;
304
+ output += `─────────────────────────────────────\n`;
305
+
306
+ for (const vote of result.votes) {
307
+ if (vote.choice) {
308
+ output += `šŸ¦† **${vote.nickname}** voted: **${vote.choice}**`;
309
+ output += ` (confidence: ${vote.confidence}%)\n`;
310
+ if (vote.reasoning) {
311
+ output += ` šŸ’­ "${vote.reasoning}"\n`;
312
+ }
313
+ } else {
314
+ output += `šŸ¦† **${vote.nickname}**: āŒ Invalid vote\n`;
315
+ }
316
+ output += `\n`;
317
+ }
318
+
319
+ output += `═══════════════════════════════════════\n`;
320
+ output += `šŸ“Š ${result.validVotes}/${result.totalVoters} valid votes\n`;
321
+
322
+ return output;
323
+ }
324
+ }
@@ -0,0 +1,383 @@
1
+ import { ProviderManager } from '../providers/manager.js';
2
+ import {
3
+ DebateFormat,
4
+ DebatePosition,
5
+ DebateParticipant,
6
+ DebateArgument,
7
+ DebateResult,
8
+ } from '../config/types.js';
9
+ import { logger } from '../utils/logger.js';
10
+
11
+ export interface DuckDebateArgs {
12
+ prompt: string;
13
+ rounds?: number;
14
+ providers?: string[];
15
+ format: DebateFormat;
16
+ synthesizer?: string;
17
+ }
18
+
19
+ const DEFAULT_ROUNDS = 3;
20
+
21
+ export async function duckDebateTool(
22
+ providerManager: ProviderManager,
23
+ args: Record<string, unknown>
24
+ ) {
25
+ const {
26
+ prompt,
27
+ rounds = DEFAULT_ROUNDS,
28
+ providers,
29
+ format,
30
+ synthesizer,
31
+ } = args as unknown as DuckDebateArgs;
32
+
33
+ // Validate inputs
34
+ if (!prompt || typeof prompt !== 'string') {
35
+ throw new Error('Prompt/topic is required');
36
+ }
37
+
38
+ if (!format || !['oxford', 'socratic', 'adversarial'].includes(format)) {
39
+ throw new Error('Format must be "oxford", "socratic", or "adversarial"');
40
+ }
41
+
42
+ if (rounds < 1 || rounds > 10) {
43
+ throw new Error('Rounds must be between 1 and 10');
44
+ }
45
+
46
+ // Get providers
47
+ const allProviders = providerManager.getProviderNames();
48
+
49
+ // If providers explicitly specified but less than 2, error
50
+ if (providers && providers.length < 2) {
51
+ throw new Error('At least 2 providers are required for a debate');
52
+ }
53
+
54
+ const debateProviders = providers && providers.length >= 2
55
+ ? providers
56
+ : allProviders;
57
+
58
+ if (debateProviders.length < 2) {
59
+ throw new Error('At least 2 providers are required for a debate');
60
+ }
61
+
62
+ // Validate providers exist
63
+ for (const p of debateProviders) {
64
+ if (!allProviders.includes(p)) {
65
+ throw new Error(`Provider "${p}" not found`);
66
+ }
67
+ }
68
+
69
+ logger.info(`Starting ${format} debate with ${debateProviders.length} participants for ${rounds} rounds`);
70
+
71
+ // Assign positions based on format
72
+ const participants = assignPositions(debateProviders, format, providerManager);
73
+
74
+ // Run debate rounds
75
+ const debateRounds: DebateArgument[][] = [];
76
+
77
+ for (let roundNum = 1; roundNum <= rounds; roundNum++) {
78
+ logger.info(`Debate round ${roundNum}/${rounds}`);
79
+ const roundArguments: DebateArgument[] = [];
80
+
81
+ // Each participant argues in this round
82
+ for (const participant of participants) {
83
+ const argumentPrompt = buildArgumentPrompt(
84
+ prompt,
85
+ format,
86
+ participant,
87
+ roundNum,
88
+ debateRounds,
89
+ participants
90
+ );
91
+
92
+ const response = await providerManager.askDuck(participant.provider, argumentPrompt);
93
+
94
+ roundArguments.push({
95
+ round: roundNum,
96
+ provider: participant.provider,
97
+ nickname: participant.nickname,
98
+ position: participant.position,
99
+ content: response.content,
100
+ timestamp: new Date(),
101
+ });
102
+ }
103
+
104
+ debateRounds.push(roundArguments);
105
+ }
106
+
107
+ // Generate synthesis
108
+ const synthesizerProvider = synthesizer || debateProviders[0];
109
+ const synthesisPrompt = buildSynthesisPrompt(prompt, format, debateRounds, participants);
110
+ const synthesisResponse = await providerManager.askDuck(synthesizerProvider, synthesisPrompt);
111
+
112
+ const result: DebateResult = {
113
+ topic: prompt,
114
+ format,
115
+ participants,
116
+ rounds: debateRounds,
117
+ synthesis: synthesisResponse.content,
118
+ synthesizer: synthesizerProvider,
119
+ totalRounds: rounds,
120
+ };
121
+
122
+ // Format output
123
+ const formattedOutput = formatDebateResult(result);
124
+
125
+ logger.info(`Debate completed: ${rounds} rounds, synthesized by ${synthesizerProvider}`);
126
+
127
+ return {
128
+ content: [
129
+ {
130
+ type: 'text',
131
+ text: formattedOutput,
132
+ },
133
+ ],
134
+ };
135
+ }
136
+
137
+ function assignPositions(
138
+ providers: string[],
139
+ format: DebateFormat,
140
+ providerManager: ProviderManager
141
+ ): DebateParticipant[] {
142
+ const participants: DebateParticipant[] = [];
143
+
144
+ for (let i = 0; i < providers.length; i++) {
145
+ const provider = providers[i];
146
+ const providerInfo = providerManager.getProvider(provider);
147
+
148
+ let position: DebatePosition;
149
+
150
+ if (format === 'oxford') {
151
+ // Oxford: alternating pro/con
152
+ position = i % 2 === 0 ? 'pro' : 'con';
153
+ } else if (format === 'adversarial') {
154
+ // Adversarial: first is defender, rest are challengers (con)
155
+ position = i === 0 ? 'pro' : 'con';
156
+ } else {
157
+ // Socratic: all neutral, questioning each other
158
+ position = 'neutral';
159
+ }
160
+
161
+ participants.push({
162
+ provider,
163
+ nickname: providerInfo.nickname,
164
+ position,
165
+ });
166
+ }
167
+
168
+ return participants;
169
+ }
170
+
171
+ function buildArgumentPrompt(
172
+ topic: string,
173
+ format: DebateFormat,
174
+ participant: DebateParticipant,
175
+ round: number,
176
+ previousRounds: DebateArgument[][],
177
+ allParticipants: DebateParticipant[]
178
+ ): string {
179
+ const previousContext = buildPreviousContext(previousRounds);
180
+
181
+ if (format === 'oxford') {
182
+ return buildOxfordPrompt(topic, participant, round, previousContext);
183
+ } else if (format === 'socratic') {
184
+ return buildSocraticPrompt(topic, participant, round, previousContext, allParticipants);
185
+ } else {
186
+ return buildAdversarialPrompt(topic, participant, round, previousContext);
187
+ }
188
+ }
189
+
190
+ function buildPreviousContext(previousRounds: DebateArgument[][]): string {
191
+ if (previousRounds.length === 0) {
192
+ return '';
193
+ }
194
+
195
+ let context = '\n\nPREVIOUS ARGUMENTS:\n';
196
+ for (const round of previousRounds) {
197
+ for (const arg of round) {
198
+ const posLabel = arg.position === 'pro' ? '[PRO]' :
199
+ arg.position === 'con' ? '[CON]' : '[NEUTRAL]';
200
+ context += `\n--- Round ${arg.round} - ${arg.nickname} ${posLabel} ---\n`;
201
+ context += `${arg.content}\n`;
202
+ }
203
+ }
204
+ return context;
205
+ }
206
+
207
+ function buildOxfordPrompt(
208
+ topic: string,
209
+ participant: DebateParticipant,
210
+ round: number,
211
+ previousContext: string
212
+ ): string {
213
+ const position = participant.position === 'pro' ? 'IN FAVOR OF' : 'AGAINST';
214
+ const positionLabel = participant.position === 'pro' ? 'PRO' : 'CON';
215
+
216
+ return `You are participating in an Oxford-style debate.
217
+
218
+ TOPIC: "${topic}"
219
+
220
+ YOUR POSITION: ${position} (${positionLabel})
221
+ ROUND: ${round}
222
+
223
+ ${previousContext}
224
+
225
+ INSTRUCTIONS:
226
+ 1. Present clear, logical arguments ${position.toLowerCase()} the topic
227
+ 2. ${round > 1 ? 'Address and rebut opposing arguments from previous rounds' : 'Establish your core thesis and supporting points'}
228
+ 3. Use evidence, examples, and reasoning
229
+ 4. Be persuasive but intellectually honest
230
+ 5. Keep your argument focused and structured
231
+
232
+ Present your argument for Round ${round}:`;
233
+ }
234
+
235
+ function buildSocraticPrompt(
236
+ topic: string,
237
+ participant: DebateParticipant,
238
+ round: number,
239
+ previousContext: string,
240
+ allParticipants: DebateParticipant[]
241
+ ): string {
242
+ const otherParticipants = allParticipants
243
+ .filter(p => p.provider !== participant.provider)
244
+ .map(p => p.nickname)
245
+ .join(', ');
246
+
247
+ return `You are participating in a Socratic dialogue.
248
+
249
+ TOPIC: "${topic}"
250
+
251
+ YOUR ROLE: Philosophical inquirer exploring the topic through questions and reasoning
252
+ OTHER PARTICIPANTS: ${otherParticipants}
253
+ ROUND: ${round}
254
+
255
+ ${previousContext}
256
+
257
+ INSTRUCTIONS:
258
+ 1. ${round === 1 ? 'Begin by questioning assumptions about the topic' : 'Build on previous responses with deeper questions'}
259
+ 2. Use the Socratic method: ask probing questions that reveal underlying assumptions
260
+ 3. Offer your own perspective while remaining open to other views
261
+ 4. Seek to understand the truth through dialogue, not to "win"
262
+ 5. Challenge ideas respectfully and constructively
263
+
264
+ Present your contribution to Round ${round}:`;
265
+ }
266
+
267
+ function buildAdversarialPrompt(
268
+ topic: string,
269
+ participant: DebateParticipant,
270
+ round: number,
271
+ previousContext: string
272
+ ): string {
273
+ const role = participant.position === 'pro' ? 'DEFENDER' : 'CHALLENGER';
274
+ const instruction = participant.position === 'pro'
275
+ ? 'Defend the proposition and address all critiques raised'
276
+ : 'Attack weaknesses in the arguments, find flaws, and present counter-examples';
277
+
278
+ return `You are participating in an adversarial debate.
279
+
280
+ TOPIC: "${topic}"
281
+
282
+ YOUR ROLE: ${role}
283
+ ROUND: ${round}
284
+
285
+ ${previousContext}
286
+
287
+ INSTRUCTIONS:
288
+ 1. ${instruction}
289
+ 2. Be rigorous and thorough in your analysis
290
+ 3. ${participant.position === 'con' ? 'Identify logical fallacies, weak evidence, or missing considerations' : 'Strengthen your position against attacks'}
291
+ 4. Use concrete examples and evidence
292
+ 5. Be intellectually aggressive but fair
293
+
294
+ Present your ${role.toLowerCase()} argument for Round ${round}:`;
295
+ }
296
+
297
+ function buildSynthesisPrompt(
298
+ topic: string,
299
+ format: DebateFormat,
300
+ rounds: DebateArgument[][],
301
+ participants: DebateParticipant[]
302
+ ): string {
303
+ let transcript = '';
304
+ for (const round of rounds) {
305
+ for (const arg of round) {
306
+ const posLabel = arg.position === 'pro' ? '[PRO]' :
307
+ arg.position === 'con' ? '[CON]' : '[NEUTRAL]';
308
+ transcript += `\n--- Round ${arg.round} - ${arg.nickname} ${posLabel} ---\n`;
309
+ transcript += `${arg.content}\n`;
310
+ }
311
+ }
312
+
313
+ const participantList = participants
314
+ .map(p => `${p.nickname} (${p.position})`)
315
+ .join(', ');
316
+
317
+ return `You are the moderator synthesizing a ${format} debate.
318
+
319
+ TOPIC: "${topic}"
320
+ PARTICIPANTS: ${participantList}
321
+
322
+ DEBATE TRANSCRIPT:
323
+ ${transcript}
324
+
325
+ YOUR TASK:
326
+ 1. Summarize the key arguments from each side
327
+ 2. Identify the strongest points made
328
+ 3. Note where participants agreed or found common ground
329
+ 4. Highlight unresolved tensions or questions
330
+ 5. Provide a balanced conclusion (who had stronger arguments, or if it was a draw)
331
+ 6. Suggest what additional considerations might be valuable
332
+
333
+ Provide your synthesis:`;
334
+ }
335
+
336
+ function formatDebateResult(result: DebateResult): string {
337
+ const formatEmoji = result.format === 'oxford' ? 'šŸŽ“' :
338
+ result.format === 'socratic' ? 'šŸ›ļø' : 'āš”ļø';
339
+
340
+ let output = `${formatEmoji} **${result.format.charAt(0).toUpperCase() + result.format.slice(1)} Debate**\n`;
341
+ output += `═══════════════════════════════════════\n\n`;
342
+ output += `**Topic:** "${result.topic}"\n`;
343
+ output += `**Format:** ${result.format}\n`;
344
+ output += `**Rounds:** ${result.totalRounds}\n\n`;
345
+
346
+ // Participants
347
+ output += `**Participants:**\n`;
348
+ for (const p of result.participants) {
349
+ const posEmoji = p.position === 'pro' ? 'āœ…' : p.position === 'con' ? 'āŒ' : 'šŸ”';
350
+ output += ` ${posEmoji} ${p.nickname} (${p.position})\n`;
351
+ }
352
+ output += `\n`;
353
+
354
+ // Debate rounds
355
+ output += `**Debate Transcript:**\n`;
356
+ output += `─────────────────────────────────────\n`;
357
+
358
+ for (let i = 0; i < result.rounds.length; i++) {
359
+ output += `\nšŸ“¢ **ROUND ${i + 1}**\n`;
360
+
361
+ for (const arg of result.rounds[i]) {
362
+ const posEmoji = arg.position === 'pro' ? 'āœ…' : arg.position === 'con' ? 'āŒ' : 'šŸ”';
363
+ output += `\n${posEmoji} **${arg.nickname}** [${arg.position.toUpperCase()}]:\n`;
364
+
365
+ // Truncate long arguments
366
+ const displayContent = arg.content.length > 800
367
+ ? arg.content.substring(0, 800) + '...[truncated]'
368
+ : arg.content;
369
+ output += `${displayContent}\n`;
370
+ }
371
+ }
372
+
373
+ // Synthesis
374
+ output += `\n═══════════════════════════════════════\n`;
375
+ output += `šŸŽÆ **Synthesis** (by ${result.synthesizer})\n`;
376
+ output += `─────────────────────────────────────\n`;
377
+ output += `${result.synthesis}\n`;
378
+
379
+ output += `\n═══════════════════════════════════════\n`;
380
+ output += `šŸ“Š ${result.totalRounds} rounds completed with ${result.participants.length} participants\n`;
381
+
382
+ return output;
383
+ }