mcp-rubber-duck 1.9.5 → 1.11.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 (92) hide show
  1. package/.eslintrc.json +3 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +62 -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 +2 -1
  9. package/dist/providers/enhanced-manager.d.ts +7 -0
  10. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  11. package/dist/providers/enhanced-manager.js +36 -0
  12. package/dist/providers/enhanced-manager.js.map +1 -1
  13. package/dist/providers/manager.d.ts +1 -0
  14. package/dist/providers/manager.d.ts.map +1 -1
  15. package/dist/providers/manager.js +33 -0
  16. package/dist/providers/manager.js.map +1 -1
  17. package/dist/server.d.ts +2 -0
  18. package/dist/server.d.ts.map +1 -1
  19. package/dist/server.js +154 -36
  20. package/dist/server.js.map +1 -1
  21. package/dist/services/progress.d.ts +27 -0
  22. package/dist/services/progress.d.ts.map +1 -0
  23. package/dist/services/progress.js +50 -0
  24. package/dist/services/progress.js.map +1 -0
  25. package/dist/services/task-manager.d.ts +56 -0
  26. package/dist/services/task-manager.d.ts.map +1 -0
  27. package/dist/services/task-manager.js +134 -0
  28. package/dist/services/task-manager.js.map +1 -0
  29. package/dist/tools/compare-ducks.d.ts +2 -1
  30. package/dist/tools/compare-ducks.d.ts.map +1 -1
  31. package/dist/tools/compare-ducks.js +26 -3
  32. package/dist/tools/compare-ducks.js.map +1 -1
  33. package/dist/tools/duck-council.d.ts +2 -1
  34. package/dist/tools/duck-council.d.ts.map +1 -1
  35. package/dist/tools/duck-council.js +7 -3
  36. package/dist/tools/duck-council.js.map +1 -1
  37. package/dist/tools/duck-debate.d.ts +2 -1
  38. package/dist/tools/duck-debate.d.ts.map +1 -1
  39. package/dist/tools/duck-debate.js +43 -1
  40. package/dist/tools/duck-debate.js.map +1 -1
  41. package/dist/tools/duck-iterate.d.ts +2 -1
  42. package/dist/tools/duck-iterate.d.ts.map +1 -1
  43. package/dist/tools/duck-iterate.js +13 -1
  44. package/dist/tools/duck-iterate.js.map +1 -1
  45. package/dist/tools/duck-vote.d.ts +2 -1
  46. package/dist/tools/duck-vote.d.ts.map +1 -1
  47. package/dist/tools/duck-vote.js +30 -3
  48. package/dist/tools/duck-vote.js.map +1 -1
  49. package/dist/tools/get-usage-stats.d.ts.map +1 -1
  50. package/dist/tools/get-usage-stats.js +13 -0
  51. package/dist/tools/get-usage-stats.js.map +1 -1
  52. package/dist/ui/compare-ducks/mcp-app.html +187 -0
  53. package/dist/ui/duck-debate/mcp-app.html +182 -0
  54. package/dist/ui/duck-vote/mcp-app.html +168 -0
  55. package/dist/ui/usage-stats/mcp-app.html +192 -0
  56. package/jest.config.js +1 -0
  57. package/package.json +7 -3
  58. package/src/providers/enhanced-manager.ts +49 -0
  59. package/src/providers/manager.ts +45 -0
  60. package/src/server.ts +187 -34
  61. package/src/services/progress.ts +59 -0
  62. package/src/services/task-manager.ts +162 -0
  63. package/src/tools/compare-ducks.ts +34 -3
  64. package/src/tools/duck-council.ts +15 -4
  65. package/src/tools/duck-debate.ts +58 -1
  66. package/src/tools/duck-iterate.ts +20 -1
  67. package/src/tools/duck-vote.ts +38 -3
  68. package/src/tools/get-usage-stats.ts +14 -0
  69. package/src/ui/compare-ducks/app.ts +88 -0
  70. package/src/ui/compare-ducks/mcp-app.html +102 -0
  71. package/src/ui/duck-debate/app.ts +111 -0
  72. package/src/ui/duck-debate/mcp-app.html +97 -0
  73. package/src/ui/duck-vote/app.ts +128 -0
  74. package/src/ui/duck-vote/mcp-app.html +83 -0
  75. package/src/ui/usage-stats/app.ts +156 -0
  76. package/src/ui/usage-stats/mcp-app.html +107 -0
  77. package/tests/duck-debate.test.ts +83 -1
  78. package/tests/duck-iterate.test.ts +81 -0
  79. package/tests/duck-vote.test.ts +73 -1
  80. package/tests/providers.test.ts +121 -0
  81. package/tests/services/progress.test.ts +137 -0
  82. package/tests/services/task-manager.test.ts +344 -0
  83. package/tests/tools/compare-ducks-ui.test.ts +135 -0
  84. package/tests/tools/compare-ducks.test.ts +22 -1
  85. package/tests/tools/duck-council.test.ts +19 -0
  86. package/tests/tools/duck-debate-ui.test.ts +234 -0
  87. package/tests/tools/duck-vote-ui.test.ts +172 -0
  88. package/tests/tools/get-usage-stats.test.ts +3 -1
  89. package/tests/tools/usage-stats-ui.test.ts +130 -0
  90. package/tests/ui-build.test.ts +53 -0
  91. package/tsconfig.json +1 -1
  92. package/vite.config.ts +19 -0
@@ -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);
@@ -0,0 +1,83 @@
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 Vote</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
+ .question { font-size: 1.2em; margin-bottom: 16px; text-align: center; }
17
+ .winner-card {
18
+ background: #e8f5e9;
19
+ border: 2px solid #4caf50;
20
+ border-radius: 12px;
21
+ padding: 16px;
22
+ text-align: center;
23
+ margin-bottom: 12px;
24
+ }
25
+ .winner-card.no-winner { background: #fafafa; border-color: #ccc; }
26
+ .winner-label { display: block; font-size: 0.8em; text-transform: uppercase; letter-spacing: 1px; opacity: 0.7; }
27
+ .winner-name { display: block; font-size: 1.3em; font-weight: 700; margin-top: 4px; }
28
+ .tie-note { display: block; font-size: 0.8em; opacity: 0.6; margin-top: 4px; }
29
+ .consensus-badge {
30
+ display: inline-block;
31
+ color: #fff;
32
+ padding: 4px 16px;
33
+ border-radius: 20px;
34
+ font-weight: 600;
35
+ font-size: 0.85em;
36
+ margin-bottom: 16px;
37
+ }
38
+ h3 { margin-bottom: 8px; font-size: 1em; opacity: 0.8; }
39
+ .tally-section { margin-bottom: 20px; }
40
+ .bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
41
+ .bar-row.winner-row .bar-label { font-weight: 700; }
42
+ .bar-label { width: 120px; font-size: 0.9em; text-align: right; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
43
+ .bar-track { flex: 1; height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden; }
44
+ .bar-fill { height: 100%; background: #42a5f5; border-radius: 10px; transition: width 0.3s; }
45
+ .winner-row .bar-fill { background: #4caf50; }
46
+ .bar-value { font-size: 0.8em; width: 110px; flex-shrink: 0; }
47
+ .voters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
48
+ .voter-card {
49
+ background: #fff;
50
+ border: 1px solid #e0e0e0;
51
+ border-radius: 10px;
52
+ padding: 12px;
53
+ }
54
+ .voter-card.invalid-vote { opacity: 0.5; }
55
+ .voter-name { font-weight: 700; margin-bottom: 4px; }
56
+ .voter-choice { font-size: 0.9em; color: #1565c0; margin-bottom: 6px; }
57
+ .voter-choice.invalid { color: #ef5350; }
58
+ .confidence-bar-wrap { height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
59
+ .confidence-bar { height: 100%; background: #66bb6a; border-radius: 3px; }
60
+ .confidence-label { font-size: 0.75em; opacity: 0.6; margin-bottom: 6px; }
61
+ .reasoning { font-size: 0.85em; }
62
+ .reasoning summary { cursor: pointer; opacity: 0.7; }
63
+ .reasoning p { margin-top: 4px; padding: 8px; background: #f5f5f5; border-radius: 6px; }
64
+ .footer { text-align: center; margin-top: 16px; font-size: 0.85em; opacity: 0.6; }
65
+ .error-banner { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; text-align: center; font-weight: 600; }
66
+ @media (prefers-color-scheme: dark) {
67
+ body { background: #1a1a2e; color: #e0e0e0; }
68
+ .winner-card { background: #1b3a4b; border-color: #0f3460; color: #e0e0e0; }
69
+ .winner-card.no-winner { background: #16213e; border-color: #333; }
70
+ .voter-card { background: #16213e; border-color: #0f3460; color: #e0e0e0; }
71
+ .voter-choice { color: #64b5f6; }
72
+ .bar-track { background: #0d1b2a; }
73
+ .confidence-bar-wrap { background: #0d1b2a; }
74
+ .reasoning p { background: #0d1b2a; color: #d0d0d0; }
75
+ .error-banner { background: #5c1a1a; color: #ff8a80; }
76
+ }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <div id="app"><p style="text-align:center;opacity:0.5">Waiting for results...</p></div>
81
+ <script type="module" src="./app.ts"></script>
82
+ </body>
83
+ </html>
@@ -0,0 +1,156 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ interface UsageData {
4
+ period: string;
5
+ startDate: string;
6
+ endDate: string;
7
+ totals: {
8
+ requests: number;
9
+ promptTokens: number;
10
+ completionTokens: number;
11
+ cacheHits: number;
12
+ errors: number;
13
+ estimatedCostUSD?: number;
14
+ };
15
+ usage: Record<string, Record<string, {
16
+ requests: number;
17
+ promptTokens: number;
18
+ completionTokens: number;
19
+ cacheHits: number;
20
+ errors: number;
21
+ }>>;
22
+ costByProvider?: Record<string, number>;
23
+ }
24
+
25
+ const periodLabels: Record<string, string> = {
26
+ today: 'Today',
27
+ '7d': 'Last 7 Days',
28
+ '30d': 'Last 30 Days',
29
+ all: 'All Time',
30
+ };
31
+
32
+ const app = new App({ name: 'UsageStats', version: '1.0.0' }, {});
33
+
34
+ app.ontoolresult = (params) => {
35
+ const container = document.getElementById('app')!;
36
+ if (params.isError) {
37
+ container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
38
+ return;
39
+ }
40
+
41
+ const content = params.content;
42
+ if (!content || !Array.isArray(content) || content.length < 2) {
43
+ container.innerHTML = `<div class="error-banner">No structured data received</div>`;
44
+ return;
45
+ }
46
+
47
+ try {
48
+ const data: UsageData = JSON.parse(
49
+ (content[1] as { type: string; text: string }).text
50
+ );
51
+ render(data);
52
+ } catch {
53
+ container.innerHTML = `<div class="error-banner">Failed to parse usage data</div>`;
54
+ }
55
+ };
56
+
57
+ function render(data: UsageData) {
58
+ const container = document.getElementById('app')!;
59
+ const periodLabel = periodLabels[data.period] || data.period;
60
+ const totalTokens = data.totals.promptTokens + data.totals.completionTokens;
61
+
62
+ let html = `<div class="header">`;
63
+ html += `<h2>Usage Statistics</h2>`;
64
+ html += `<div class="period-badge">${esc(periodLabel)}</div>`;
65
+ html += `<div class="date-range">${esc(data.startDate)} to ${esc(data.endDate)}</div>`;
66
+ html += `</div>`;
67
+
68
+ // Summary cards
69
+ html += `<div class="summary-cards">`;
70
+ html += summaryCard('Requests', fmt(data.totals.requests), 'req');
71
+ html += summaryCard('Total Tokens', fmt(totalTokens), 'tok');
72
+ html += summaryCard('Cache Hits', fmt(data.totals.cacheHits), 'cache');
73
+ html += summaryCard('Errors', fmt(data.totals.errors), data.totals.errors > 0 ? 'err' : 'ok');
74
+ if (data.totals.estimatedCostUSD !== undefined) {
75
+ html += summaryCard('Est. Cost', '$' + formatCost(data.totals.estimatedCostUSD), 'cost');
76
+ }
77
+ html += `</div>`;
78
+
79
+ // Provider breakdown
80
+ const providers = Object.keys(data.usage);
81
+ if (providers.length > 0) {
82
+ // Token distribution bar
83
+ const maxTokens = Math.max(
84
+ ...providers.map((p) => {
85
+ let t = 0;
86
+ for (const m of Object.values(data.usage[p])) t += m.promptTokens + m.completionTokens;
87
+ return t;
88
+ }),
89
+ 1
90
+ );
91
+
92
+ html += `<div class="section"><h3>By Provider</h3>`;
93
+ for (const provider of providers) {
94
+ const models = data.usage[provider];
95
+ let providerTokens = 0;
96
+ let providerRequests = 0;
97
+ for (const m of Object.values(models)) {
98
+ providerTokens += m.promptTokens + m.completionTokens;
99
+ providerRequests += m.requests;
100
+ }
101
+ const pct = (providerTokens / maxTokens) * 100;
102
+ const cost = data.costByProvider?.[provider];
103
+
104
+ html += `<details class="provider-row" open>`;
105
+ html += `<summary>`;
106
+ html += `<span class="provider-name">${esc(provider)}</span>`;
107
+ html += `<span class="provider-stats">${fmt(providerRequests)} req &middot; ${fmt(providerTokens)} tokens`;
108
+ if (cost !== undefined) html += ` &middot; $${formatCost(cost)}`;
109
+ html += `</span></summary>`;
110
+ html += `<div class="token-bar"><div class="token-fill" style="width:${pct}%"></div></div>`;
111
+
112
+ // Model detail table
113
+ html += `<table class="model-table"><thead><tr>`;
114
+ html += `<th>Model</th><th>Requests</th><th>Prompt</th><th>Completion</th><th>Cache</th><th>Errors</th>`;
115
+ html += `</tr></thead><tbody>`;
116
+ for (const [model, stats] of Object.entries(models)) {
117
+ html += `<tr>`;
118
+ html += `<td>${esc(model)}</td>`;
119
+ html += `<td>${fmt(stats.requests)}</td>`;
120
+ html += `<td>${fmt(stats.promptTokens)}</td>`;
121
+ html += `<td>${fmt(stats.completionTokens)}</td>`;
122
+ html += `<td>${fmt(stats.cacheHits)}</td>`;
123
+ html += `<td>${stats.errors > 0 ? `<span class="err-text">${fmt(stats.errors)}</span>` : '0'}</td>`;
124
+ html += `</tr>`;
125
+ }
126
+ html += `</tbody></table></details>`;
127
+ }
128
+ html += `</div>`;
129
+ } else {
130
+ html += `<div class="empty">No usage data for this period.</div>`;
131
+ }
132
+
133
+ container.innerHTML = html;
134
+ }
135
+
136
+ function summaryCard(label: string, value: string, kind: string) {
137
+ return `<div class="card card-${kind}"><div class="card-value">${value}</div><div class="card-label">${label}</div></div>`;
138
+ }
139
+
140
+ function fmt(n: number): string {
141
+ return n.toLocaleString();
142
+ }
143
+
144
+ function formatCost(cost: number): string {
145
+ if (cost < 0.01) return cost.toFixed(6);
146
+ if (cost < 1) return cost.toFixed(4);
147
+ return cost.toFixed(2);
148
+ }
149
+
150
+ function esc(s: string): string {
151
+ const d = document.createElement('div');
152
+ d.textContent = s;
153
+ return d.innerHTML;
154
+ }
155
+
156
+ app.connect().catch(console.error);