mcp-rubber-duck 1.2.5 → 1.3.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 (41) hide show
  1. package/.eslintrc.json +1 -0
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +116 -2
  4. package/dist/config/types.d.ts +78 -0
  5. package/dist/config/types.d.ts.map +1 -1
  6. package/dist/server.d.ts.map +1 -1
  7. package/dist/server.js +150 -0
  8. package/dist/server.js.map +1 -1
  9. package/dist/services/consensus.d.ts +28 -0
  10. package/dist/services/consensus.d.ts.map +1 -0
  11. package/dist/services/consensus.js +257 -0
  12. package/dist/services/consensus.js.map +1 -0
  13. package/dist/tools/duck-debate.d.ts +16 -0
  14. package/dist/tools/duck-debate.d.ts.map +1 -0
  15. package/dist/tools/duck-debate.js +272 -0
  16. package/dist/tools/duck-debate.js.map +1 -0
  17. package/dist/tools/duck-iterate.d.ts +14 -0
  18. package/dist/tools/duck-iterate.d.ts.map +1 -0
  19. package/dist/tools/duck-iterate.js +195 -0
  20. package/dist/tools/duck-iterate.js.map +1 -0
  21. package/dist/tools/duck-judge.d.ts +15 -0
  22. package/dist/tools/duck-judge.d.ts.map +1 -0
  23. package/dist/tools/duck-judge.js +208 -0
  24. package/dist/tools/duck-judge.js.map +1 -0
  25. package/dist/tools/duck-vote.d.ts +14 -0
  26. package/dist/tools/duck-vote.d.ts.map +1 -0
  27. package/dist/tools/duck-vote.js +46 -0
  28. package/dist/tools/duck-vote.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/config/types.ts +92 -0
  31. package/src/server.ts +154 -0
  32. package/src/services/consensus.ts +324 -0
  33. package/src/tools/duck-debate.ts +383 -0
  34. package/src/tools/duck-iterate.ts +253 -0
  35. package/src/tools/duck-judge.ts +301 -0
  36. package/src/tools/duck-vote.ts +87 -0
  37. package/tests/consensus.test.ts +282 -0
  38. package/tests/duck-debate.test.ts +286 -0
  39. package/tests/duck-iterate.test.ts +249 -0
  40. package/tests/duck-judge.test.ts +296 -0
  41. package/tests/duck-vote.test.ts +250 -0
package/src/server.ts CHANGED
@@ -27,6 +27,10 @@ import { listDucksTool } from './tools/list-ducks.js';
27
27
  import { listModelsTool } from './tools/list-models.js';
28
28
  import { compareDucksTool } from './tools/compare-ducks.js';
29
29
  import { duckCouncilTool } from './tools/duck-council.js';
30
+ import { duckVoteTool } from './tools/duck-vote.js';
31
+ import { duckJudgeTool } from './tools/duck-judge.js';
32
+ import { duckIterateTool } from './tools/duck-iterate.js';
33
+ import { duckDebateTool } from './tools/duck-debate.js';
30
34
 
31
35
  // Import MCP tools
32
36
  import { getPendingApprovalsTool } from './tools/get-pending-approvals.js';
@@ -162,6 +166,18 @@ export class RubberDuckServer {
162
166
  }
163
167
  return await duckCouncilTool(this.providerManager, args || {});
164
168
 
169
+ case 'duck_vote':
170
+ return await duckVoteTool(this.providerManager, args || {});
171
+
172
+ case 'duck_judge':
173
+ return await duckJudgeTool(this.providerManager, args || {});
174
+
175
+ case 'duck_iterate':
176
+ return await duckIterateTool(this.providerManager, args || {});
177
+
178
+ case 'duck_debate':
179
+ return await duckDebateTool(this.providerManager, args || {});
180
+
165
181
  // MCP-specific tools
166
182
  case 'get_pending_approvals':
167
183
  if (!this.approvalService) {
@@ -487,6 +503,144 @@ export class RubberDuckServer {
487
503
  required: ['prompt'],
488
504
  },
489
505
  },
506
+ {
507
+ name: 'duck_vote',
508
+ description: 'Have multiple ducks vote on options with reasoning. Returns vote tally, confidence scores, and consensus level.',
509
+ inputSchema: {
510
+ type: 'object',
511
+ properties: {
512
+ question: {
513
+ type: 'string',
514
+ description: 'The question to vote on (e.g., "Best approach for error handling?")',
515
+ },
516
+ options: {
517
+ type: 'array',
518
+ items: { type: 'string' },
519
+ minItems: 2,
520
+ maxItems: 10,
521
+ description: 'The options to vote on (2-10 options)',
522
+ },
523
+ voters: {
524
+ type: 'array',
525
+ items: { type: 'string' },
526
+ description: 'List of provider names to vote (optional, uses all if not specified)',
527
+ },
528
+ require_reasoning: {
529
+ type: 'boolean',
530
+ default: true,
531
+ description: 'Require ducks to explain their vote (default: true)',
532
+ },
533
+ },
534
+ required: ['question', 'options'],
535
+ },
536
+ },
537
+ {
538
+ name: 'duck_judge',
539
+ description: 'Have one duck evaluate and rank other ducks\' responses. Use after duck_council to get a comparative evaluation.',
540
+ inputSchema: {
541
+ type: 'object',
542
+ properties: {
543
+ responses: {
544
+ type: 'array',
545
+ items: {
546
+ type: 'object',
547
+ properties: {
548
+ provider: { type: 'string' },
549
+ nickname: { type: 'string' },
550
+ model: { type: 'string' },
551
+ content: { type: 'string' },
552
+ },
553
+ required: ['provider', 'nickname', 'content'],
554
+ },
555
+ minItems: 2,
556
+ description: 'Array of duck responses to evaluate (from duck_council output)',
557
+ },
558
+ judge: {
559
+ type: 'string',
560
+ description: 'Provider name of the judge duck (optional, uses first available)',
561
+ },
562
+ criteria: {
563
+ type: 'array',
564
+ items: { type: 'string' },
565
+ description: 'Evaluation criteria (default: ["accuracy", "completeness", "clarity"])',
566
+ },
567
+ persona: {
568
+ type: 'string',
569
+ description: 'Judge persona (e.g., "senior engineer", "security expert")',
570
+ },
571
+ },
572
+ required: ['responses'],
573
+ },
574
+ },
575
+ {
576
+ name: 'duck_iterate',
577
+ description: 'Iteratively refine a response between two ducks. One generates, the other critiques/improves, alternating for multiple rounds.',
578
+ inputSchema: {
579
+ type: 'object',
580
+ properties: {
581
+ prompt: {
582
+ type: 'string',
583
+ description: 'The initial prompt/task to iterate on',
584
+ },
585
+ iterations: {
586
+ type: 'number',
587
+ minimum: 1,
588
+ maximum: 10,
589
+ default: 3,
590
+ description: 'Number of iteration rounds (default: 3, max: 10)',
591
+ },
592
+ providers: {
593
+ type: 'array',
594
+ items: { type: 'string' },
595
+ minItems: 2,
596
+ maxItems: 2,
597
+ description: 'Exactly 2 provider names for the ping-pong iteration',
598
+ },
599
+ mode: {
600
+ type: 'string',
601
+ enum: ['refine', 'critique-improve'],
602
+ description: 'refine: each duck improves the previous response. critique-improve: alternates between critiquing and improving.',
603
+ },
604
+ },
605
+ required: ['prompt', 'providers', 'mode'],
606
+ },
607
+ },
608
+ {
609
+ name: 'duck_debate',
610
+ description: 'Structured multi-round debate between ducks. Supports oxford (pro/con), socratic (questioning), and adversarial (attack/defend) formats.',
611
+ inputSchema: {
612
+ type: 'object',
613
+ properties: {
614
+ prompt: {
615
+ type: 'string',
616
+ description: 'The debate topic or proposition',
617
+ },
618
+ rounds: {
619
+ type: 'number',
620
+ minimum: 1,
621
+ maximum: 10,
622
+ default: 3,
623
+ description: 'Number of debate rounds (default: 3)',
624
+ },
625
+ providers: {
626
+ type: 'array',
627
+ items: { type: 'string' },
628
+ minItems: 2,
629
+ description: 'Provider names to participate (min 2, uses all if not specified)',
630
+ },
631
+ format: {
632
+ type: 'string',
633
+ enum: ['oxford', 'socratic', 'adversarial'],
634
+ description: 'Debate format: oxford (pro/con), socratic (questioning), adversarial (attack/defend)',
635
+ },
636
+ synthesizer: {
637
+ type: 'string',
638
+ description: 'Provider to synthesize the debate (optional, uses first provider)',
639
+ },
640
+ },
641
+ required: ['prompt', 'format'],
642
+ },
643
+ },
490
644
  ];
491
645
 
492
646
  // Add MCP-specific tools if enabled
@@ -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
+ }