mcp-rubber-duck 1.9.5 → 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 (54) hide show
  1. package/.eslintrc.json +3 -1
  2. package/CHANGELOG.md +12 -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 +2 -1
  9. package/dist/server.d.ts +1 -0
  10. package/dist/server.d.ts.map +1 -1
  11. package/dist/server.js +62 -4
  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 +79 -4
  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/tools/compare-ducks-ui.test.ts +135 -0
  47. package/tests/tools/compare-ducks.test.ts +3 -1
  48. package/tests/tools/duck-debate-ui.test.ts +234 -0
  49. package/tests/tools/duck-vote-ui.test.ts +172 -0
  50. package/tests/tools/get-usage-stats.test.ts +3 -1
  51. package/tests/tools/usage-stats-ui.test.ts +130 -0
  52. package/tests/ui-build.test.ts +53 -0
  53. package/tsconfig.json +1 -1
  54. package/vite.config.ts +19 -0
@@ -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);
@@ -0,0 +1,107 @@
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>Usage Stats</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
+ .header h2 { margin-bottom: 4px; }
18
+ .period-badge {
19
+ display: inline-block;
20
+ background: #e3f2fd;
21
+ color: #1565c0;
22
+ padding: 2px 14px;
23
+ border-radius: 16px;
24
+ font-weight: 600;
25
+ font-size: 0.85em;
26
+ margin-bottom: 4px;
27
+ }
28
+ .date-range { font-size: 0.8em; opacity: 0.6; }
29
+ .summary-cards {
30
+ display: flex;
31
+ gap: 12px;
32
+ flex-wrap: wrap;
33
+ justify-content: center;
34
+ margin-bottom: 24px;
35
+ }
36
+ .card {
37
+ background: #fff;
38
+ border: 1px solid #e0e0e0;
39
+ border-radius: 12px;
40
+ padding: 12px 20px;
41
+ text-align: center;
42
+ min-width: 120px;
43
+ flex: 1;
44
+ }
45
+ .card-value { font-size: 1.4em; font-weight: 700; }
46
+ .card-label { font-size: 0.8em; opacity: 0.6; }
47
+ .card-req .card-value { color: #1565c0; }
48
+ .card-tok .card-value { color: #6a1b9a; }
49
+ .card-cache .card-value { color: #2e7d32; }
50
+ .card-err .card-value { color: #c62828; }
51
+ .card-ok .card-value { color: #2e7d32; }
52
+ .card-cost .card-value { color: #e65100; }
53
+ .section { margin-bottom: 20px; }
54
+ .section h3 { margin-bottom: 12px; font-size: 1em; }
55
+ .provider-row {
56
+ background: #fff;
57
+ border-radius: 10px;
58
+ margin-bottom: 8px;
59
+ overflow: hidden;
60
+ }
61
+ .provider-row summary {
62
+ padding: 10px 16px;
63
+ cursor: pointer;
64
+ display: flex;
65
+ justify-content: space-between;
66
+ align-items: center;
67
+ font-weight: 600;
68
+ }
69
+ .provider-stats { font-weight: 400; font-size: 0.85em; opacity: 0.7; }
70
+ .token-bar { height: 6px; background: #e0e0e0; margin: 0 16px 8px; border-radius: 3px; overflow: hidden; }
71
+ .token-fill { height: 100%; background: linear-gradient(90deg, #42a5f5, #7e57c2); border-radius: 3px; }
72
+ .model-table { width: 100%; border-collapse: collapse; font-size: 0.85em; margin: 0 0 8px; }
73
+ .model-table th {
74
+ text-align: left;
75
+ padding: 6px 12px;
76
+ background: #f5f5f5;
77
+ font-weight: 600;
78
+ font-size: 0.9em;
79
+ }
80
+ .model-table td { padding: 6px 12px; border-top: 1px solid #eee; }
81
+ .err-text { color: #c62828; font-weight: 600; }
82
+ .empty { text-align: center; padding: 24px; opacity: 0.5; }
83
+ .error-banner { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; text-align: center; font-weight: 600; }
84
+ @media (prefers-color-scheme: dark) {
85
+ body { background: #1a1a2e; color: #e0e0e0; }
86
+ .card { background: #16213e; border-color: #0f3460; color: #e0e0e0; }
87
+ .card-req .card-value { color: #64b5f6; }
88
+ .card-tok .card-value { color: #ce93d8; }
89
+ .card-cache .card-value { color: #81c784; }
90
+ .card-err .card-value { color: #ef5350; }
91
+ .card-ok .card-value { color: #81c784; }
92
+ .card-cost .card-value { color: #ffb74d; }
93
+ .period-badge { background: #0f3460; color: #a0c4ff; }
94
+ .provider-row { background: #16213e; color: #e0e0e0; }
95
+ .model-table th { background: #0d1b2a; color: #b0b0b0; }
96
+ .model-table td { border-color: #0f3460; color: #d0d0d0; }
97
+ .token-bar { background: #0d1b2a; }
98
+ .err-text { color: #ef5350; }
99
+ .error-banner { background: #5c1a1a; color: #ff8a80; }
100
+ }
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <div id="app"><p style="text-align:center;opacity:0.5">Waiting for results...</p></div>
105
+ <script type="module" src="./app.ts"></script>
106
+ </body>
107
+ </html>
@@ -163,7 +163,9 @@ describe('duckDebateTool', () => {
163
163
  rounds: 2,
164
164
  });
165
165
 
166
- expect(result.content).toHaveLength(1);
166
+ expect(result.content).toHaveLength(2);
167
+ expect(result.content[1].type).toBe('text');
168
+ expect(() => JSON.parse(result.content[1].text)).not.toThrow();
167
169
  const text = result.content[0].text;
168
170
 
169
171
  expect(text).toContain('Oxford Debate');
@@ -115,8 +115,10 @@ describe('duckVoteTool', () => {
115
115
  options: ['Option A', 'Option B'],
116
116
  });
117
117
 
118
- expect(result.content).toHaveLength(1);
118
+ expect(result.content).toHaveLength(2);
119
119
  expect(result.content[0].type).toBe('text');
120
+ expect(result.content[1].type).toBe('text');
121
+ expect(() => JSON.parse(result.content[1].text)).not.toThrow();
120
122
 
121
123
  const text = result.content[0].text;
122
124
  expect(text).toContain('Vote Results');