mcp-rubber-duck 1.9.4 → 1.10.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 (55) hide show
  1. package/.eslintrc.json +3 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +54 -10
  4. package/assets/ext-apps-compare.png +0 -0
  5. package/assets/ext-apps-debate.png +0 -0
  6. package/assets/ext-apps-usage-stats.png +0 -0
  7. package/assets/ext-apps-vote.png +0 -0
  8. package/audit-ci.json +3 -1
  9. package/dist/server.d.ts +5 -2
  10. package/dist/server.d.ts.map +1 -1
  11. package/dist/server.js +414 -498
  12. package/dist/server.js.map +1 -1
  13. package/dist/tools/compare-ducks.d.ts.map +1 -1
  14. package/dist/tools/compare-ducks.js +19 -0
  15. package/dist/tools/compare-ducks.js.map +1 -1
  16. package/dist/tools/duck-debate.d.ts.map +1 -1
  17. package/dist/tools/duck-debate.js +24 -0
  18. package/dist/tools/duck-debate.js.map +1 -1
  19. package/dist/tools/duck-vote.d.ts.map +1 -1
  20. package/dist/tools/duck-vote.js +23 -0
  21. package/dist/tools/duck-vote.js.map +1 -1
  22. package/dist/tools/get-usage-stats.d.ts.map +1 -1
  23. package/dist/tools/get-usage-stats.js +13 -0
  24. package/dist/tools/get-usage-stats.js.map +1 -1
  25. package/dist/ui/compare-ducks/mcp-app.html +187 -0
  26. package/dist/ui/duck-debate/mcp-app.html +182 -0
  27. package/dist/ui/duck-vote/mcp-app.html +168 -0
  28. package/dist/ui/usage-stats/mcp-app.html +192 -0
  29. package/jest.config.js +1 -0
  30. package/package.json +7 -3
  31. package/src/server.ts +491 -523
  32. package/src/tools/compare-ducks.ts +20 -0
  33. package/src/tools/duck-debate.ts +27 -0
  34. package/src/tools/duck-vote.ts +24 -0
  35. package/src/tools/get-usage-stats.ts +14 -0
  36. package/src/ui/compare-ducks/app.ts +88 -0
  37. package/src/ui/compare-ducks/mcp-app.html +102 -0
  38. package/src/ui/duck-debate/app.ts +111 -0
  39. package/src/ui/duck-debate/mcp-app.html +97 -0
  40. package/src/ui/duck-vote/app.ts +128 -0
  41. package/src/ui/duck-vote/mcp-app.html +83 -0
  42. package/src/ui/usage-stats/app.ts +156 -0
  43. package/src/ui/usage-stats/mcp-app.html +107 -0
  44. package/tests/duck-debate.test.ts +3 -1
  45. package/tests/duck-vote.test.ts +3 -1
  46. package/tests/tool-annotations.test.ts +208 -41
  47. package/tests/tools/compare-ducks-ui.test.ts +135 -0
  48. package/tests/tools/compare-ducks.test.ts +3 -1
  49. package/tests/tools/duck-debate-ui.test.ts +234 -0
  50. package/tests/tools/duck-vote-ui.test.ts +172 -0
  51. package/tests/tools/get-usage-stats.test.ts +3 -1
  52. package/tests/tools/usage-stats-ui.test.ts +130 -0
  53. package/tests/ui-build.test.ts +53 -0
  54. package/tsconfig.json +1 -1
  55. package/vite.config.ts +19 -0
@@ -55,12 +55,32 @@ export async function compareDucksTool(
55
55
 
56
56
  logger.info(`Compared ${responses.length} ducks, ${successCount} successful`);
57
57
 
58
+ // Build structured data for UI consumption
59
+ const structuredData = responses.map(r => ({
60
+ provider: r.provider,
61
+ nickname: r.nickname,
62
+ model: r.model,
63
+ content: r.content,
64
+ latency: r.latency,
65
+ tokens: r.usage ? {
66
+ prompt: r.usage.prompt_tokens,
67
+ completion: r.usage.completion_tokens,
68
+ total: r.usage.total_tokens,
69
+ } : null,
70
+ cached: r.cached,
71
+ error: r.content.startsWith('Error:') ? r.content : undefined,
72
+ }));
73
+
58
74
  return {
59
75
  content: [
60
76
  {
61
77
  type: 'text',
62
78
  text: response,
63
79
  },
80
+ {
81
+ type: 'text',
82
+ text: JSON.stringify(structuredData),
83
+ },
64
84
  ],
65
85
  };
66
86
  }
@@ -124,12 +124,39 @@ export async function duckDebateTool(
124
124
 
125
125
  logger.info(`Debate completed: ${rounds} rounds, synthesized by ${synthesizerProvider}`);
126
126
 
127
+ // Build structured data for UI consumption
128
+ const structuredData = {
129
+ topic: result.topic,
130
+ format: result.format,
131
+ totalRounds: result.totalRounds,
132
+ participants: result.participants.map(p => ({
133
+ provider: p.provider,
134
+ nickname: p.nickname,
135
+ position: p.position,
136
+ })),
137
+ rounds: result.rounds.map(roundArgs =>
138
+ roundArgs.map(arg => ({
139
+ round: arg.round,
140
+ provider: arg.provider,
141
+ nickname: arg.nickname,
142
+ position: arg.position,
143
+ content: arg.content,
144
+ }))
145
+ ),
146
+ synthesis: result.synthesis,
147
+ synthesizer: result.synthesizer,
148
+ };
149
+
127
150
  return {
128
151
  content: [
129
152
  {
130
153
  type: 'text',
131
154
  text: formattedOutput,
132
155
  },
156
+ {
157
+ type: 'text',
158
+ text: JSON.stringify(structuredData),
159
+ },
133
160
  ],
134
161
  };
135
162
  }
@@ -76,12 +76,36 @@ export async function duckVoteTool(
76
76
  `winner: ${aggregatedResult.winner || 'none'}`
77
77
  );
78
78
 
79
+ // Build structured data for UI consumption
80
+ const structuredData = {
81
+ question: aggregatedResult.question,
82
+ options: aggregatedResult.options,
83
+ winner: aggregatedResult.winner,
84
+ isTie: aggregatedResult.isTie,
85
+ tally: aggregatedResult.tally,
86
+ confidenceByOption: aggregatedResult.confidenceByOption,
87
+ votes: aggregatedResult.votes.map(v => ({
88
+ voter: v.voter,
89
+ nickname: v.nickname,
90
+ choice: v.choice,
91
+ confidence: v.confidence,
92
+ reasoning: v.reasoning,
93
+ })),
94
+ totalVoters: aggregatedResult.totalVoters,
95
+ validVotes: aggregatedResult.validVotes,
96
+ consensusLevel: aggregatedResult.consensusLevel,
97
+ };
98
+
79
99
  return {
80
100
  content: [
81
101
  {
82
102
  type: 'text',
83
103
  text: formattedOutput,
84
104
  },
105
+ {
106
+ type: 'text',
107
+ text: JSON.stringify(structuredData),
108
+ },
85
109
  ],
86
110
  };
87
111
  }
@@ -75,12 +75,26 @@ export function getUsageStatsTool(
75
75
 
76
76
  logger.info(`Retrieved usage stats for period: ${period}`);
77
77
 
78
+ // Build structured data for UI consumption
79
+ const structuredData = {
80
+ period: stats.period,
81
+ startDate: stats.startDate,
82
+ endDate: stats.endDate,
83
+ totals: stats.totals,
84
+ usage: stats.usage,
85
+ costByProvider: stats.costByProvider,
86
+ };
87
+
78
88
  return {
79
89
  content: [
80
90
  {
81
91
  type: 'text',
82
92
  text: output,
83
93
  },
94
+ {
95
+ type: 'text',
96
+ text: JSON.stringify(structuredData),
97
+ },
84
98
  ],
85
99
  };
86
100
  }
@@ -0,0 +1,88 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ interface CompareResponse {
4
+ provider: string;
5
+ nickname: string;
6
+ model: string;
7
+ content: string;
8
+ latency: number;
9
+ tokens: { prompt: number; completion: number; total: number } | null;
10
+ cached: boolean;
11
+ error?: string;
12
+ }
13
+
14
+ const app = new App({ name: 'CompareDucks', version: '1.0.0' }, {});
15
+
16
+ app.ontoolresult = (params) => {
17
+ const container = document.getElementById('app')!;
18
+ if (params.isError) {
19
+ container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
20
+ return;
21
+ }
22
+
23
+ // Parse JSON from second content item
24
+ const content = params.content;
25
+ if (!content || !Array.isArray(content) || content.length < 2) {
26
+ container.innerHTML = `<div class="error-banner">No structured data received</div>`;
27
+ return;
28
+ }
29
+
30
+ try {
31
+ const data: CompareResponse[] = JSON.parse(
32
+ (content[1] as { type: string; text: string }).text
33
+ );
34
+ render(data);
35
+ } catch {
36
+ container.innerHTML = `<div class="error-banner">Failed to parse response data</div>`;
37
+ }
38
+ };
39
+
40
+ function render(responses: CompareResponse[]) {
41
+ const container = document.getElementById('app')!;
42
+ const successCount = responses.filter((r) => !r.error).length;
43
+
44
+ let html = `<div class="summary-bar">${successCount}/${responses.length} ducks responded successfully</div>`;
45
+ html += `<div class="grid">`;
46
+
47
+ for (const r of responses) {
48
+ const isError = !!r.error;
49
+ const latencyClass =
50
+ r.latency < 2000 ? 'fast' : r.latency < 5000 ? 'medium' : 'slow';
51
+
52
+ html += `<div class="card${isError ? ' card-error' : ''}">`;
53
+ html += `<div class="card-header">`;
54
+ html += `<span class="nickname">${esc(r.nickname)}</span>`;
55
+ html += `<span class="provider">${esc(r.provider)}</span>`;
56
+ html += `</div>`;
57
+
58
+ if (!isError) {
59
+ html += `<div class="badges">`;
60
+ html += `<span class="badge model">${esc(r.model)}</span>`;
61
+ if (r.tokens) {
62
+ html += `<span class="badge tokens">${r.tokens.total} tokens</span>`;
63
+ }
64
+ if (r.cached) {
65
+ html += `<span class="badge cached">Cached</span>`;
66
+ }
67
+ html += `</div>`;
68
+ html += `<div class="latency-bar ${latencyClass}" style="width:${Math.min(100, (r.latency / 10000) * 100)}%"></div>`;
69
+ html += `<div class="latency-label">${r.latency}ms</div>`;
70
+ html += `<div class="content"><pre>${esc(r.content)}</pre></div>`;
71
+ } else {
72
+ html += `<div class="content error-text"><pre>${esc(r.content)}</pre></div>`;
73
+ }
74
+
75
+ html += `</div>`;
76
+ }
77
+
78
+ html += `</div>`;
79
+ container.innerHTML = html;
80
+ }
81
+
82
+ function esc(s: string): string {
83
+ const d = document.createElement('div');
84
+ d.textContent = s;
85
+ return d.innerHTML;
86
+ }
87
+
88
+ app.connect().catch(console.error);
@@ -0,0 +1,102 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Compare Ducks</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ line-height: 1.5;
12
+ color: #1a1a2e;
13
+ background: #f8f9fa;
14
+ padding: 16px;
15
+ }
16
+ .summary-bar {
17
+ background: #e3f2fd;
18
+ color: #1565c0;
19
+ padding: 8px 16px;
20
+ border-radius: 8px;
21
+ font-weight: 600;
22
+ margin-bottom: 16px;
23
+ text-align: center;
24
+ }
25
+ .grid {
26
+ display: grid;
27
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
28
+ gap: 16px;
29
+ }
30
+ .card {
31
+ background: #fff;
32
+ border: 1px solid #e0e0e0;
33
+ border-radius: 12px;
34
+ padding: 16px;
35
+ overflow: hidden;
36
+ }
37
+ .card-error { border-color: #ef5350; }
38
+ .card-header {
39
+ display: flex;
40
+ justify-content: space-between;
41
+ align-items: center;
42
+ margin-bottom: 8px;
43
+ }
44
+ .nickname { font-weight: 700; font-size: 1.1em; }
45
+ .provider { font-size: 0.85em; opacity: 0.7; }
46
+ .badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
47
+ .badge {
48
+ font-size: 0.75em;
49
+ padding: 2px 8px;
50
+ border-radius: 12px;
51
+ background: #e8eaf6;
52
+ color: #3949ab;
53
+ font-weight: 500;
54
+ }
55
+ .badge.cached { background: #e8f5e9; color: #2e7d32; }
56
+ .latency-bar {
57
+ height: 4px;
58
+ border-radius: 2px;
59
+ margin-bottom: 4px;
60
+ min-width: 4px;
61
+ }
62
+ .latency-bar.fast { background: #4caf50; }
63
+ .latency-bar.medium { background: #ff9800; }
64
+ .latency-bar.slow { background: #f44336; }
65
+ .latency-label { font-size: 0.75em; opacity: 0.6; margin-bottom: 8px; }
66
+ .content pre {
67
+ white-space: pre-wrap;
68
+ word-break: break-word;
69
+ font-size: 0.9em;
70
+ background: #f5f5f5;
71
+ padding: 12px;
72
+ border-radius: 8px;
73
+ max-height: 300px;
74
+ overflow-y: auto;
75
+ }
76
+ .error-text pre { color: #ef5350; }
77
+ .error-banner {
78
+ background: #ffebee;
79
+ color: #c62828;
80
+ padding: 12px;
81
+ border-radius: 8px;
82
+ text-align: center;
83
+ font-weight: 600;
84
+ }
85
+ @media (prefers-color-scheme: dark) {
86
+ body { background: #1a1a2e; color: #e0e0e0; }
87
+ .card { background: #16213e; border-color: #0f3460; color: #e0e0e0; }
88
+ .badge { background: #0f3460; color: #a0c4ff; }
89
+ .summary-bar { background: #0f3460; color: #a0c4ff; }
90
+ .content pre { background: #0d1b2a; color: #d0d0d0; }
91
+ .error-banner { background: #5c1a1a; color: #ff8a80; }
92
+ .card-error { border-color: #8b3a3a; }
93
+ .latency-label { color: #a0a0a0; }
94
+ .provider { color: #9e9e9e; }
95
+ }
96
+ </style>
97
+ </head>
98
+ <body>
99
+ <div id="app"><p style="text-align:center;opacity:0.5">Waiting for results...</p></div>
100
+ <script type="module" src="./app.ts"></script>
101
+ </body>
102
+ </html>
@@ -0,0 +1,111 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ interface DebateData {
4
+ topic: string;
5
+ format: 'oxford' | 'socratic' | 'adversarial';
6
+ totalRounds: number;
7
+ participants: { provider: string; nickname: string; position: string }[];
8
+ rounds: { round: number; provider: string; nickname: string; position: string; content: string }[][];
9
+ synthesis: string;
10
+ synthesizer: string;
11
+ }
12
+
13
+ const formatMeta: Record<string, { emoji: string; label: string; style: string }> = {
14
+ oxford: { emoji: '\uD83C\uDF93', label: 'Oxford', style: 'oxford' },
15
+ socratic: { emoji: '\uD83C\uDFDB\uFE0F', label: 'Socratic', style: 'socratic' },
16
+ adversarial: { emoji: '\u2694\uFE0F', label: 'Adversarial', style: 'adversarial' },
17
+ };
18
+
19
+ const positionColors: Record<string, string> = {
20
+ pro: '#4caf50',
21
+ con: '#f44336',
22
+ neutral: '#9e9e9e',
23
+ };
24
+
25
+ const app = new App({ name: 'DuckDebate', version: '1.0.0' }, {});
26
+
27
+ app.ontoolresult = (params) => {
28
+ const container = document.getElementById('app')!;
29
+ if (params.isError) {
30
+ container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
31
+ return;
32
+ }
33
+
34
+ const content = params.content;
35
+ if (!content || !Array.isArray(content) || content.length < 2) {
36
+ container.innerHTML = `<div class="error-banner">No structured data received</div>`;
37
+ return;
38
+ }
39
+
40
+ try {
41
+ const data: DebateData = JSON.parse(
42
+ (content[1] as { type: string; text: string }).text
43
+ );
44
+ render(data);
45
+ } catch {
46
+ container.innerHTML = `<div class="error-banner">Failed to parse debate data</div>`;
47
+ }
48
+ };
49
+
50
+ function render(data: DebateData) {
51
+ const container = document.getElementById('app')!;
52
+ const fmt = formatMeta[data.format] || formatMeta.oxford;
53
+
54
+ let html = `<div class="debate ${fmt.style}">`;
55
+
56
+ // Header
57
+ html += `<div class="header">`;
58
+ html += `<div class="format-badge">${fmt.emoji} ${fmt.label} Debate</div>`;
59
+ html += `<h2 class="topic">${esc(data.topic)}</h2>`;
60
+ html += `<div class="meta">${data.totalRounds} rounds &middot; ${data.participants.length} participants</div>`;
61
+ html += `</div>`;
62
+
63
+ // Participants
64
+ html += `<div class="participants">`;
65
+ for (const p of data.participants) {
66
+ const color = positionColors[p.position] || '#9e9e9e';
67
+ html += `<span class="participant" style="border-color:${color}">`;
68
+ html += `<span class="pos-dot" style="background:${color}"></span>`;
69
+ html += `${esc(p.nickname)} <small>(${p.position})</small>`;
70
+ html += `</span>`;
71
+ }
72
+ html += `</div>`;
73
+
74
+ // Rounds
75
+ html += `<div class="rounds">`;
76
+ for (let i = 0; i < data.rounds.length; i++) {
77
+ const round = data.rounds[i];
78
+ html += `<details class="round">`;
79
+ html += `<summary class="round-header">Round ${i + 1}</summary>`;
80
+ html += `<div class="round-body">`;
81
+ for (const arg of round) {
82
+ const color = positionColors[arg.position] || '#9e9e9e';
83
+ html += `<div class="argument" style="border-left-color:${color}">`;
84
+ html += `<div class="arg-header">`;
85
+ html += `<span class="arg-name">${esc(arg.nickname)}</span>`;
86
+ html += `<span class="arg-pos" style="color:${color}">${arg.position.toUpperCase()}</span>`;
87
+ html += `</div>`;
88
+ html += `<div class="arg-content">${esc(arg.content)}</div>`;
89
+ html += `</div>`;
90
+ }
91
+ html += `</div></details>`;
92
+ }
93
+ html += `</div>`;
94
+
95
+ // Synthesis
96
+ html += `<div class="synthesis">`;
97
+ html += `<h3>Synthesis <small>by ${esc(data.synthesizer)}</small></h3>`;
98
+ html += `<div class="synthesis-content">${esc(data.synthesis)}</div>`;
99
+ html += `</div>`;
100
+
101
+ html += `</div>`;
102
+ container.innerHTML = html;
103
+ }
104
+
105
+ function esc(s: string): string {
106
+ const d = document.createElement('div');
107
+ d.textContent = s;
108
+ return d.innerHTML;
109
+ }
110
+
111
+ app.connect().catch(console.error);
@@ -0,0 +1,97 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Duck Debate</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ line-height: 1.5;
12
+ color: #1a1a2e;
13
+ background: #f8f9fa;
14
+ padding: 16px;
15
+ }
16
+ .header { text-align: center; margin-bottom: 20px; }
17
+ .format-badge {
18
+ display: inline-block;
19
+ padding: 4px 16px;
20
+ border-radius: 20px;
21
+ font-weight: 600;
22
+ font-size: 0.9em;
23
+ margin-bottom: 8px;
24
+ }
25
+ .oxford .format-badge { background: #e8eaf6; color: #283593; }
26
+ .socratic .format-badge { background: #f3e5f5; color: #6a1b9a; }
27
+ .adversarial .format-badge { background: #fbe9e7; color: #bf360c; }
28
+ .topic { font-size: 1.2em; margin-bottom: 4px; }
29
+ .meta { font-size: 0.85em; opacity: 0.6; }
30
+ .participants {
31
+ display: flex;
32
+ gap: 8px;
33
+ flex-wrap: wrap;
34
+ justify-content: center;
35
+ margin-bottom: 20px;
36
+ }
37
+ .participant {
38
+ display: inline-flex;
39
+ align-items: center;
40
+ gap: 6px;
41
+ padding: 4px 12px;
42
+ border: 2px solid;
43
+ border-radius: 20px;
44
+ font-size: 0.85em;
45
+ background: #fff;
46
+ }
47
+ .pos-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
48
+ .rounds { margin-bottom: 20px; }
49
+ .round { margin-bottom: 8px; }
50
+ .round-header {
51
+ cursor: pointer;
52
+ padding: 8px 16px;
53
+ background: #e8eaf6;
54
+ border-radius: 8px;
55
+ font-weight: 600;
56
+ font-size: 0.95em;
57
+ }
58
+ .round-body { padding: 12px 0; }
59
+ .argument {
60
+ background: #fff;
61
+ border-left: 4px solid;
62
+ border-radius: 0 8px 8px 0;
63
+ padding: 12px 16px;
64
+ margin-bottom: 10px;
65
+ }
66
+ .arg-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
67
+ .arg-name { font-weight: 700; }
68
+ .arg-pos { font-size: 0.75em; font-weight: 700; letter-spacing: 0.5px; }
69
+ .arg-content { white-space: pre-wrap; word-break: break-word; font-size: 0.9em; }
70
+ .synthesis {
71
+ background: #fffde7;
72
+ border: 2px solid #ffd54f;
73
+ border-radius: 12px;
74
+ padding: 16px;
75
+ }
76
+ .synthesis h3 { margin-bottom: 8px; }
77
+ .synthesis small { font-weight: 400; opacity: 0.6; }
78
+ .synthesis-content { white-space: pre-wrap; word-break: break-word; font-size: 0.9em; }
79
+ .error-banner { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; text-align: center; font-weight: 600; }
80
+ @media (prefers-color-scheme: dark) {
81
+ body { background: #1a1a2e; color: #e0e0e0; }
82
+ .argument { background: #16213e; color: #e0e0e0; }
83
+ .synthesis { background: #1b3a4b; border-color: #0f3460; color: #e0e0e0; }
84
+ .round-header { background: #16213e; color: #e0e0e0; }
85
+ .participant { background: #16213e; color: #e0e0e0; }
86
+ .oxford .format-badge { background: #1a237e; color: #9fa8da; }
87
+ .socratic .format-badge { background: #4a148c; color: #ce93d8; }
88
+ .adversarial .format-badge { background: #4e1a00; color: #ffab91; }
89
+ .error-banner { background: #5c1a1a; color: #ff8a80; }
90
+ }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div id="app"><p style="text-align:center;opacity:0.5">Waiting for results...</p></div>
95
+ <script type="module" src="./app.ts"></script>
96
+ </body>
97
+ </html>
@@ -0,0 +1,128 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ interface VoteData {
4
+ question: string;
5
+ options: string[];
6
+ winner: string | null;
7
+ isTie: boolean;
8
+ tally: Record<string, number>;
9
+ confidenceByOption: Record<string, number>;
10
+ votes: {
11
+ voter: string;
12
+ nickname: string;
13
+ choice: string;
14
+ confidence: number;
15
+ reasoning: string;
16
+ }[];
17
+ totalVoters: number;
18
+ validVotes: number;
19
+ consensusLevel: 'unanimous' | 'majority' | 'plurality' | 'split' | 'none';
20
+ }
21
+
22
+ const consensusConfig: Record<string, { color: string; label: string }> = {
23
+ unanimous: { color: '#4caf50', label: 'Unanimous' },
24
+ majority: { color: '#2196f3', label: 'Majority' },
25
+ plurality: { color: '#ff9800', label: 'Plurality' },
26
+ split: { color: '#ff5722', label: 'Split' },
27
+ none: { color: '#9e9e9e', label: 'No Consensus' },
28
+ };
29
+
30
+ const app = new App({ name: 'DuckVote', version: '1.0.0' }, {});
31
+
32
+ app.ontoolresult = (params) => {
33
+ const container = document.getElementById('app')!;
34
+ if (params.isError) {
35
+ container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
36
+ return;
37
+ }
38
+
39
+ const content = params.content;
40
+ if (!content || !Array.isArray(content) || content.length < 2) {
41
+ container.innerHTML = `<div class="error-banner">No structured data received</div>`;
42
+ return;
43
+ }
44
+
45
+ try {
46
+ const data: VoteData = JSON.parse(
47
+ (content[1] as { type: string; text: string }).text
48
+ );
49
+ render(data);
50
+ } catch {
51
+ container.innerHTML = `<div class="error-banner">Failed to parse vote data</div>`;
52
+ }
53
+ };
54
+
55
+ function render(data: VoteData) {
56
+ const container = document.getElementById('app')!;
57
+ const cfg = consensusConfig[data.consensusLevel] || consensusConfig.none;
58
+ const maxVotes = Math.max(...Object.values(data.tally), 1);
59
+
60
+ let html = `<h2 class="question">${esc(data.question)}</h2>`;
61
+
62
+ // Winner card
63
+ if (data.winner) {
64
+ html += `<div class="winner-card">`;
65
+ html += `<span class="winner-label">Winner</span>`;
66
+ html += `<span class="winner-name">${esc(data.winner)}</span>`;
67
+ if (data.isTie) {
68
+ html += `<span class="tie-note">Tie-breaker by confidence</span>`;
69
+ }
70
+ html += `</div>`;
71
+ } else {
72
+ html += `<div class="winner-card no-winner"><span class="winner-label">No valid votes</span></div>`;
73
+ }
74
+
75
+ // Consensus badge
76
+ html += `<div class="consensus-badge" style="background:${cfg.color}">${cfg.label}</div>`;
77
+
78
+ // Bar chart
79
+ html += `<div class="tally-section"><h3>Vote Tally</h3>`;
80
+ const sortedOptions = [...data.options].sort(
81
+ (a, b) => (data.tally[b] || 0) - (data.tally[a] || 0)
82
+ );
83
+ for (const opt of sortedOptions) {
84
+ const votes = data.tally[opt] || 0;
85
+ const conf = data.confidenceByOption[opt] || 0;
86
+ const pct = (votes / maxVotes) * 100;
87
+ const isWinner = opt === data.winner;
88
+ html += `<div class="bar-row${isWinner ? ' winner-row' : ''}">`;
89
+ html += `<div class="bar-label">${esc(opt)}</div>`;
90
+ html += `<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>`;
91
+ html += `<div class="bar-value">${votes} vote${votes !== 1 ? 's' : ''} (${conf}%)</div>`;
92
+ html += `</div>`;
93
+ }
94
+ html += `</div>`;
95
+
96
+ // Individual voters
97
+ html += `<div class="voters-section"><h3>Individual Votes</h3><div class="voters-grid">`;
98
+ for (const v of data.votes) {
99
+ const hasChoice = !!v.choice;
100
+ html += `<div class="voter-card${!hasChoice ? ' invalid-vote' : ''}">`;
101
+ html += `<div class="voter-name">${esc(v.nickname)}</div>`;
102
+ if (hasChoice) {
103
+ html += `<div class="voter-choice">${esc(v.choice)}</div>`;
104
+ html += `<div class="confidence-bar-wrap"><div class="confidence-bar" style="width:${v.confidence}%"></div></div>`;
105
+ html += `<div class="confidence-label">${v.confidence}% confidence</div>`;
106
+ if (v.reasoning) {
107
+ html += `<details class="reasoning"><summary>Reasoning</summary><p>${esc(v.reasoning)}</p></details>`;
108
+ }
109
+ } else {
110
+ html += `<div class="voter-choice invalid">Invalid vote</div>`;
111
+ }
112
+ html += `</div>`;
113
+ }
114
+ html += `</div></div>`;
115
+
116
+ // Footer
117
+ html += `<div class="footer">${data.validVotes}/${data.totalVoters} valid votes</div>`;
118
+
119
+ container.innerHTML = html;
120
+ }
121
+
122
+ function esc(s: string): string {
123
+ const d = document.createElement('div');
124
+ d.textContent = s;
125
+ return d.innerHTML;
126
+ }
127
+
128
+ app.connect().catch(console.error);