smoothie-code 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -1,82 +1,64 @@
1
1
  # Smoothie
2
2
 
3
- <p align="center">
4
- <img src="banner-v2.svg" alt="Smoothie — multi-model review for Claude Code" width="100%">
5
- </p>
3
+ Multi-model code review for AI coding agents. Query Codex, Gemini, Grok, DeepSeek and more in parallel — get one blended answer.
6
4
 
7
- Multi-model review plugin for Claude Code. Sends your problem or plan to multiple AI models simultaneously, then Claude judges all responses and serves you one blended result.
8
-
9
- **Two model tracks:**
10
- - **Codex** — Codex CLI, authenticated via ChatGPT account OAuth
11
- - **OpenRouter** — single API key, models selected at install time from a live ranked list
5
+ **[Website](https://smoothiecode.com)** · **[Docs](https://smoothiecode.com/docs)** · **[Leaderboard](https://smoothiecode.com/leaderboard)** · **[@smoothie_code](https://x.com/smoothie_code)**
12
6
 
13
7
  ## Install
14
8
 
15
9
  ```bash
16
- git clone https://github.com/hotairbag/smoothie && cd smoothie && bash install.sh
10
+ npx smoothie-code
17
11
  ```
18
12
 
19
- The installer walks you through everything: dependencies, Codex auth, OpenRouter key, and model selection.
13
+ Works with **Claude Code**, **Gemini CLI**, **Codex CLI**, and **Cursor**.
20
14
 
21
- Restart Claude Code after install.
15
+ ## Features
22
16
 
23
- ## Usage
17
+ - `/smoothie <problem>` — blend across all models, get one answer
18
+ - `/smoothie-pr` — multi-model PR review
19
+ - `/smoothie --deep` — full context mode with cost estimate
20
+ - **Auto-blend** — plans and PRs reviewed automatically before you approve
21
+ - **Leaderboard** — weekly token rankings at [smoothiecode.com/leaderboard](https://smoothiecode.com/leaderboard)
22
+ - **Stats & sharing** — `smoothie stats`, `smoothie share`
24
23
 
25
- ### Slash command
26
- ```
27
- /smoothie <your problem or question>
28
- ```
24
+ ## Platform support
29
25
 
30
- ### Auto-blend (plans)
31
- When enabled, every plan is automatically reviewed by all models before you see it. Claude revises the plan with their feedback, then presents the improved version for approval. Zero effort.
32
-
33
- Enable during install, or toggle anytime in `config.json`:
34
- ```json
35
- { "auto_blend": true }
36
- ```
26
+ | Feature | Claude Code | Gemini CLI | Codex CLI | Cursor |
27
+ |---------|------------|-----------|-----------|--------|
28
+ | MCP server | ✓ | ✓ | ✓ (STDIO) | ✓ |
29
+ | Slash commands | | | — | — |
30
+ | Auto-blend hooks | ✓ | ✓ | ⚠ experimental | Rule-based |
31
+ | `/smoothie` | ✓ | ✓ | — | — |
32
+ | `smoothie blend` CLI | ✓ | ✓ | ✓ | ✓ |
37
33
 
38
- Adds 30-90s to plan approval while models respond.
34
+ ## CLI
39
35
 
40
- ### Manage models
41
36
  ```bash
42
- smoothie models # re-pick from top models
43
- smoothie models add openai/gpt-5.4
44
- smoothie models remove openai/gpt-5.4
45
- smoothie models list
37
+ smoothie models # pick models
38
+ smoothie auto on|off # toggle auto-blend
39
+ smoothie blend "<prompt>" # run a blend
40
+ smoothie blend --deep "..." # deep blend with full context
41
+ smoothie stats # usage stats
42
+ smoothie share # share last report
43
+ smoothie leaderboard # view rankings
44
+ smoothie help # all commands
46
45
  ```
47
- No restart needed — config is read fresh on each blend.
48
46
 
49
47
  ## How it works
50
48
 
51
49
  ```
52
- Claude Code
50
+ Your IDE (Claude/Gemini/Cursor)
53
51
  |
54
- |-- /smoothie <context> <- manual slash command
55
- |-- PreToolUse hook <- auto-blend on ExitPlanMode
56
- \-- MCP Server
57
- \-- smoothie_blend(prompt)
58
- |-- Queries all models in parallel
59
- |-- Streams live progress to terminal
60
- \-- Returns all responses to Claude
52
+ |-- /smoothie or auto-blend hook
53
+ \-- MCP Server smoothie_blend(prompt)
54
+ |-- Queries all models in parallel
55
+ |-- Returns responses to the judge AI
56
+ \-- Judge gives you one blended answer
61
57
  ```
62
58
 
63
- **Auto-blend flow:**
64
- ```
65
- Claude presents plan → ExitPlanMode hook fires → Smoothie blend runs
66
- → Results injected as context → Claude revises plan → You approve
67
- ```
68
-
69
- Claude acts as judge. Raw model outputs are never shown. Claude absorbs everything and hands you one result.
70
-
71
- ## File overview
59
+ ## Links
72
60
 
73
- | File | Purpose |
74
- |---|---|
75
- | `src/index.ts` | MCP server exposing `smoothie_blend` tool |
76
- | `src/blend-cli.ts` | Standalone blend runner (used by hooks) |
77
- | `src/select-models.ts` | Interactive model picker (OpenRouter API) |
78
- | `auto-blend-hook.sh` | PreToolUse hook — auto-blends plans |
79
- | `plan-hook.sh` | Stop hook — plan mode hint (fallback) |
80
- | `install.sh` | One-command installer |
81
- | `config.json` | Model selection + auto_blend flag |
82
- | `.env` | API keys (gitignored) |
61
+ - [Documentation](https://smoothiecode.com/docs)
62
+ - [Leaderboard](https://smoothiecode.com/leaderboard)
63
+ - [npm](https://www.npmjs.com/package/smoothie-code)
64
+ - [OpenRouter Usage](https://openrouter.ai/apps?url=https%3A%2F%2Fsmoothiecode.com)
@@ -66,6 +66,36 @@ Provide concise, actionable feedback. Focus only on things that should change."
66
66
  # Run the blend (progress shows on stderr, results on stdout)
67
67
  BLEND_RESULTS=$(echo "$REVIEW_PROMPT" | node "$SCRIPT_DIR/dist/blend-cli.js" 2>/dev/stderr)
68
68
 
69
+ # Generate share link (metadata only, no raw content)
70
+ SHARE_URL=""
71
+ SHARE_PARAMS=$(echo "$BLEND_RESULTS" | node -e "
72
+ const fs=require('fs');
73
+ let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
74
+ try {
75
+ const r=JSON.parse(d);
76
+ const models=r.results.map(m=>m.model).join(',');
77
+ const times=r.results.map(m=>m.elapsed_s||0).join(',');
78
+ const tokens=r.results.map(m=>(m.tokens&&m.tokens.total)||0).join(',');
79
+ const responded=r.results.filter(m=>!m.response.startsWith('Error:')&&m.response!=='No response content'&&m.response!=='(empty response)').length;
80
+ let github='',judge='Claude Code';
81
+ try{const c=JSON.parse(fs.readFileSync('$SCRIPT_DIR/config.json','utf8'));github=c.github||'';const p=process.env.SMOOTHIE_PLATFORM||'claude';judge={claude:'Claude Code',gemini:'Gemini CLI',codex:'Codex CLI',cursor:'Cursor'}[p]||'Claude Code';}catch{}
82
+ let params='models='+encodeURIComponent(models)+'&times='+encodeURIComponent(times)+'&tokens='+encodeURIComponent(tokens)+'&type=plan&suggestions='+responded+'&judge='+encodeURIComponent(judge);
83
+ if(github)params+='&user='+encodeURIComponent(github);
84
+ console.log(params);
85
+ } catch { console.log(''); }
86
+ });
87
+ " 2>/dev/null)
88
+
89
+ if [ -n "$SHARE_PARAMS" ]; then
90
+ SHARE_URL=$(curl -s --max-time 5 --connect-timeout 3 -X POST "https://api.smoothiecode.com/api/share" \
91
+ -H "Content-Type: application/json" \
92
+ -d "{\"type\":\"report\",\"data\":\"$SHARE_PARAMS\"}" 2>/dev/null | node -e "
93
+ let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
94
+ try{console.log(JSON.parse(d).url)}catch{console.log('')}
95
+ });
96
+ " 2>/dev/null) || true
97
+ fi
98
+
69
99
  if [ -z "$BLEND_RESULTS" ]; then
70
100
  echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
71
101
  exit 0
@@ -78,14 +108,19 @@ $BLEND_RESULTS
78
108
 
79
109
  IMPORTANT — You must do TWO things:
80
110
 
81
- 1. FIRST, show a brief Smoothie summary to the user so they know the blend ran. Format it like:
111
+ 1. FIRST, show a Smoothie report table. Use elapsed_s and tokens from each result:
112
+
113
+ 🧃 Smoothie reviewed this plan (N models, Xs total):
82
114
 
83
- 🧃 Smoothie reviewed this plan (N models):
84
- ModelName — one-line summary of their key feedback
85
- ModelName one-line summary of their key feedback
86
- (skip models that returned errors or empty responses)
115
+ | Model | Time | Tokens | Verdict | Feedback |
116
+ |-------|------|--------|---------|----------|
117
+ | ModelName | 3.1s | 1,847 | useful | one-line summary |
118
+ | ModelName | 8.2s | 2,104 | generic | one-line summary |
119
+ (skip errored/empty models. Verdict = useful, generic, or irrelevant)
120
+ $([ -n "$SHARE_URL" ] && echo "
121
+ Share this report: $SHARE_URL")
87
122
 
88
- 2. THEN revise the plan incorporating valid suggestions. Discard irrelevant ones."
123
+ 2. THEN proceed with implementation as planned, incorporating valid Smoothie suggestions as you go. Do NOT stop to revise — start coding immediately."
89
124
 
90
125
  # Return: allow ExitPlanMode but inject blend results
91
126
  node -e "
package/bin/smoothie CHANGED
@@ -43,6 +43,208 @@ case "$1" in
43
43
  ;;
44
44
  esac
45
45
  ;;
46
+ stats)
47
+ HISTORY="$SCRIPT_DIR/.smoothie-history.jsonl"
48
+ if [ ! -f "$HISTORY" ]; then
49
+ echo " No history yet. Run a blend first."
50
+ exit 0
51
+ fi
52
+ node -e "
53
+ const fs = require('fs');
54
+ const lines = fs.readFileSync('$HISTORY', 'utf8').trim().split('\n').map(l => JSON.parse(l));
55
+
56
+ const totalBlends = lines.length;
57
+ const totalTokens = lines.reduce((s, e) => s + e.models.reduce((s2, m) => s2 + (m.tokens?.total || 0), 0), 0);
58
+ const totalTime = lines.reduce((s, e) => s + Math.max(...e.models.map(m => m.elapsed_s || 0)), 0);
59
+ const errors = lines.reduce((s, e) => s + e.models.filter(m => m.error).length, 0);
60
+ const successes = lines.reduce((s, e) => s + e.models.filter(m => !m.error).length, 0);
61
+
62
+ // Model usage counts
63
+ const modelStats = {};
64
+ for (const entry of lines) {
65
+ for (const m of entry.models) {
66
+ if (!modelStats[m.model]) modelStats[m.model] = { calls: 0, tokens: 0, errors: 0, totalTime: 0 };
67
+ modelStats[m.model].calls++;
68
+ modelStats[m.model].tokens += m.tokens?.total || 0;
69
+ modelStats[m.model].totalTime += m.elapsed_s || 0;
70
+ if (m.error) modelStats[m.model].errors++;
71
+ }
72
+ }
73
+
74
+ // Sort by calls
75
+ const sorted = Object.entries(modelStats).sort((a, b) => b[1].calls - a[1].calls);
76
+
77
+ console.log('');
78
+ console.log(' 🧃 Smoothie Stats');
79
+ console.log(' ─────────────────────────────────────────');
80
+ console.log(' Blends: ' + totalBlends + ' Tokens: ' + totalTokens.toLocaleString() + ' Time: ' + totalTime.toFixed(0) + 's');
81
+ console.log(' Success rate: ' + successes + '/' + (successes + errors) + ' (' + (successes / (successes + errors) * 100).toFixed(0) + '%)');
82
+ console.log('');
83
+ console.log(' Model Calls Tokens Avg time Errors');
84
+ console.log(' ─────────────────────────────────────────────────────────────────');
85
+ for (const [name, s] of sorted) {
86
+ const avg = (s.totalTime / s.calls).toFixed(1);
87
+ console.log(' ' + name.padEnd(30) + String(s.calls).padStart(5) + String(s.tokens.toLocaleString()).padStart(8) + (avg + 's').padStart(10) + String(s.errors).padStart(8));
88
+ }
89
+ console.log('');
90
+
91
+ if ('$2' === '--share') {
92
+ const rate = (successes / (successes + errors) * 100).toFixed(0);
93
+ const names = sorted.map(s => s[0]).join(',');
94
+ const mt = sorted.map(s => s[1].tokens).join(',');
95
+ const mc = sorted.map(s => s[1].calls).join(',');
96
+ const ma = sorted.map(s => (s[1].totalTime / s[1].calls).toFixed(1)).join(',');
97
+ const params = 'blends=' + totalBlends + '&tokens=' + totalTokens + '&time=' + totalTime.toFixed(0) + '&rate=' + rate + '&names=' + encodeURIComponent(names) + '&mt=' + encodeURIComponent(mt) + '&mc=' + encodeURIComponent(mc) + '&ma=' + encodeURIComponent(ma);
98
+
99
+ // Try to create short URL via API
100
+ const https = require('https');
101
+ const postData = JSON.stringify({ type: 'stats', data: params });
102
+ const req = https.request({
103
+ hostname: 'api.smoothiecode.com',
104
+ path: '/api/share',
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', 'Content-Length': postData.length },
107
+ timeout: 5000,
108
+ }, (res) => {
109
+ let body = '';
110
+ res.on('data', c => body += c);
111
+ res.on('end', () => {
112
+ try {
113
+ const shortUrl = JSON.parse(body).url;
114
+ if (shortUrl) { console.log(' ' + shortUrl); }
115
+ else { console.log(' https://smoothiecode.com/stats?' + params); }
116
+ } catch { console.log(' https://smoothiecode.com/stats?' + params); }
117
+ console.log('');
118
+ console.log(' Open in browser to save as image or copy tweet');
119
+ console.log('');
120
+ });
121
+ });
122
+ req.on('error', () => {
123
+ console.log(' https://smoothiecode.com/stats?' + params);
124
+ console.log('');
125
+ console.log(' Open in browser to save as image or copy tweet');
126
+ console.log('');
127
+ });
128
+ req.on('timeout', () => { req.destroy(); });
129
+ req.write(postData);
130
+ req.end();
131
+ } else {
132
+ const recent = lines.slice(-5).reverse();
133
+ console.log(' Recent blends:');
134
+ for (const e of recent) {
135
+ const models = e.models.filter(m => !m.error).map(m => m.model).join(', ');
136
+ const time = Math.max(...e.models.map(m => m.elapsed_s || 0)).toFixed(1);
137
+ const date = new Date(e.ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
138
+ console.log(' ' + date + ' ' + e.type.padEnd(6) + time + 's ' + models);
139
+ }
140
+ console.log('');
141
+ }
142
+ "
143
+ ;;
144
+ share)
145
+ shift
146
+ if [ -f "$SCRIPT_DIR/.last-blend.json" ]; then
147
+ PARAMS=$(node -e "
148
+ const r = JSON.parse(require('fs').readFileSync('$SCRIPT_DIR/.last-blend.json','utf8'));
149
+ const models = r.results.map(m => m.model).join(',');
150
+ const times = r.results.map(m => m.elapsed_s || 0).join(',');
151
+ const tokens = r.results.map(m => m.tokens?.total || 0).join(',');
152
+ console.log('models=' + encodeURIComponent(models) + '&times=' + encodeURIComponent(times) + '&tokens=' + encodeURIComponent(tokens) + '&type=plan&suggestions=0');
153
+ ")
154
+ # Try to create short URL
155
+ SHORT=$(curl -s -X POST "https://api.smoothiecode.com/api/share" \
156
+ -H "Content-Type: application/json" \
157
+ -d "{\"type\":\"report\",\"data\":\"$PARAMS\"}" 2>/dev/null | node -e "
158
+ let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
159
+ try{console.log(JSON.parse(d).url)}catch{console.log('')}
160
+ })
161
+ " 2>/dev/null)
162
+ if [ -n "$SHORT" ]; then
163
+ echo " $SHORT"
164
+ else
165
+ echo " https://smoothiecode.com/report?$PARAMS"
166
+ fi
167
+ echo ""
168
+ echo " Open in browser or copy to share"
169
+ else
170
+ echo " No recent blend found. Run a blend first."
171
+ fi
172
+ ;;
173
+ leaderboard)
174
+ LEADERBOARD_API="https://api.smoothiecode.com"
175
+ case "$2" in
176
+ join)
177
+ GITHUB=$(git config user.name 2>/dev/null || echo "")
178
+ if [ -z "$GITHUB" ]; then
179
+ read -p " GitHub username: " GITHUB
180
+ else
181
+ read -p " GitHub username [$GITHUB]: " INPUT_GH
182
+ GITHUB="${INPUT_GH:-$GITHUB}"
183
+ fi
184
+ node -e "
185
+ const fs = require('fs');
186
+ const c = JSON.parse(fs.readFileSync('$CONFIG','utf8'));
187
+ c.leaderboard = true;
188
+ c.github = '$GITHUB';
189
+ fs.writeFileSync('$CONFIG', JSON.stringify(c, null, 2));
190
+ "
191
+ echo " ✓ Joined as $GITHUB"
192
+ echo " Your stats will appear on the leaderboard after your next blend"
193
+ ;;
194
+ leave)
195
+ node -e "
196
+ const fs = require('fs');
197
+ const c = JSON.parse(fs.readFileSync('$CONFIG','utf8'));
198
+ const github = c.github || '';
199
+ c.leaderboard = false;
200
+ fs.writeFileSync('$CONFIG', JSON.stringify(c, null, 2));
201
+ console.log(github);
202
+ " | xargs -I{} curl -s -X DELETE "$LEADERBOARD_API/api/user/{}" > /dev/null 2>&1
203
+ echo " ✓ Left the leaderboard (remote data deleted)"
204
+ ;;
205
+ profile)
206
+ read -p " Twitter handle (e.g. @hotairbag): " TWITTER
207
+ GITHUB=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG','utf8'));console.log(c.github||'')")
208
+ if [ -z "$GITHUB" ]; then
209
+ echo " Join the leaderboard first: smoothie leaderboard join"
210
+ else
211
+ curl -s -X POST "$LEADERBOARD_API/api/profile" \
212
+ -H "Content-Type: application/json" \
213
+ -d "{\"github\":\"$GITHUB\",\"twitter\":\"$TWITTER\"}" > /dev/null 2>&1
214
+ echo " ✓ Twitter set to $TWITTER"
215
+ fi
216
+ ;;
217
+ *)
218
+ # Show leaderboard
219
+ RESULT=$(curl -s "$LEADERBOARD_API/api/leaderboard" 2>/dev/null)
220
+ if [ -z "$RESULT" ]; then
221
+ echo " Could not reach leaderboard. Check your connection."
222
+ else
223
+ node -e "
224
+ const d = JSON.parse(\`$RESULT\`);
225
+ if (!d.rankings || d.rankings.length === 0) {
226
+ console.log(' No blends this week yet.');
227
+ process.exit(0);
228
+ }
229
+ console.log('');
230
+ console.log(' 🧃 Smoothie Leaderboard — ' + d.week);
231
+ console.log(' ──────────────────────────────────────────');
232
+ const medals = ['🥇','🥈','🥉'];
233
+ for (let i = 0; i < d.rankings.length && i < 20; i++) {
234
+ const r = d.rankings[i];
235
+ const medal = medals[i] || ' ';
236
+ const tokens = r.total_tokens >= 1000 ? (r.total_tokens/1000).toFixed(1)+'k' : r.total_tokens;
237
+ const tw = r.twitter ? ' @'+r.twitter : '';
238
+ console.log(' ' + medal + ' #' + (i+1) + ' ' + r.github.padEnd(20) + tokens.padStart(8) + ' tokens ' + r.total_blends + ' blends' + tw);
239
+ }
240
+ console.log('');
241
+ console.log(' https://smoothiecode.com/leaderboard');
242
+ console.log('');
243
+ "
244
+ fi
245
+ ;;
246
+ esac
247
+ ;;
46
248
  help|--help|-h|"")
47
249
  echo ""
48
250
  echo " 🧃 Smoothie — multi-model review for Claude Code"
@@ -59,6 +261,15 @@ case "$1" in
59
261
  echo " smoothie blend \"<prompt>\" Run a blend from terminal"
60
262
  echo " smoothie blend --deep \"<prompt>\" Deep blend with full context"
61
263
  echo ""
264
+ echo " smoothie leaderboard View weekly rankings"
265
+ echo " smoothie leaderboard join Join the leaderboard"
266
+ echo " smoothie leaderboard leave Leave and delete data"
267
+ echo " smoothie leaderboard profile Set Twitter handle"
268
+ echo ""
269
+ echo " smoothie stats Usage stats and history"
270
+ echo " smoothie stats --share Shareable stats page"
271
+ echo " smoothie share Share last blend report"
272
+ echo ""
62
273
  ;;
63
274
  *)
64
275
  echo " Unknown command: $1"
package/config.json CHANGED
@@ -13,5 +13,7 @@
13
13
  "label": "Z.ai: GLM 5V Turbo"
14
14
  }
15
15
  ],
16
- "auto_blend": true
16
+ "auto_blend": true,
17
+ "leaderboard": true,
18
+ "github": "hotairbag"
17
19
  }
package/dist/blend-cli.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
10
10
  * Progress goes to stderr so it doesn't interfere with hook JSON output.
11
11
  */
12
- import { readFileSync } from 'fs';
12
+ import { readFileSync, writeFileSync } from 'fs';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { dirname, join } from 'path';
15
15
  import { execFile as execFileCb } from 'child_process';
@@ -53,7 +53,12 @@ async function queryCodex(prompt) {
53
53
  catch {
54
54
  response = '';
55
55
  }
56
- return { model: 'Codex', response: response || '(empty response)' };
56
+ const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
57
+ return {
58
+ model: 'Codex',
59
+ response: response || '(empty response)',
60
+ tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
61
+ };
57
62
  }
58
63
  catch (err) {
59
64
  const message = err instanceof Error ? err.message : String(err);
@@ -68,7 +73,7 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
68
73
  method: 'POST',
69
74
  headers: {
70
75
  'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
71
- 'HTTP-Referer': 'https://hotairbag.github.io/smoothie',
76
+ 'HTTP-Referer': 'https://smoothiecode.com',
72
77
  'X-Title': 'Smoothie',
73
78
  'Content-Type': 'application/json',
74
79
  },
@@ -81,7 +86,12 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
81
86
  clearTimeout(timer);
82
87
  const data = (await res.json());
83
88
  const text = data.choices?.[0]?.message?.content ?? 'No response content';
84
- return { model: modelLabel, response: text };
89
+ const usage = data.usage;
90
+ return {
91
+ model: modelLabel,
92
+ response: text,
93
+ tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
94
+ };
85
95
  }
86
96
  catch (err) {
87
97
  const message = err instanceof Error ? err.message : String(err);
@@ -166,20 +176,63 @@ async function main() {
166
176
  startTimes[label] = Date.now();
167
177
  return fn()
168
178
  .then((result) => {
169
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
170
- process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed}s)\n`);
171
- return result;
179
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
180
+ process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
181
+ return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
172
182
  })
173
183
  .catch((err) => {
174
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
184
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
175
185
  const message = err instanceof Error ? err.message : String(err);
176
- process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed}s)\n`);
177
- return { model: label, response: `Error: ${message}` };
186
+ process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
187
+ return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
178
188
  });
179
189
  });
180
190
  const results = await Promise.all(promises);
181
191
  process.stderr.write('\n ◆ All done.\n\n');
182
192
  // Output JSON to stdout (for hook consumption)
183
193
  process.stdout.write(JSON.stringify({ results }, null, 2));
194
+ // Save for share command + append to history
195
+ try {
196
+ const { appendFileSync } = await import('fs');
197
+ writeFileSync(join(PROJECT_ROOT, '.last-blend.json'), JSON.stringify({ results }, null, 2));
198
+ const entry = {
199
+ ts: new Date().toISOString(),
200
+ type: deep ? 'deep' : 'blend',
201
+ models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
202
+ };
203
+ appendFileSync(join(PROJECT_ROOT, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
204
+ }
205
+ catch { }
206
+ // Submit to leaderboard if opted in
207
+ try {
208
+ const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
209
+ if (cfg.leaderboard && cfg.github) {
210
+ const now = new Date();
211
+ const jan1 = new Date(now.getFullYear(), 0, 1);
212
+ const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
213
+ const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
214
+ const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
215
+ const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
216
+ const blendId = `${cfg.github}-${Date.now()}`;
217
+ await fetch('https://api.smoothiecode.com/api/submit', {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({
221
+ github: cfg.github,
222
+ blend_id: blendId,
223
+ tokens: totalTokens,
224
+ blends: 1,
225
+ models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
226
+ week,
227
+ }),
228
+ signal: AbortSignal.timeout(5000),
229
+ }).catch(() => { });
230
+ }
231
+ }
232
+ catch { }
233
+ const totalTime = Math.max(...results.map(r => r.elapsed_s || 0));
234
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokens?.total || 0), 0);
235
+ const responded = results.filter(r => !r.response.startsWith('Error:')).length;
236
+ process.stderr.write(` ${responded}/${results.length} models · ${totalTime.toFixed(1)}s · ${totalTokens} tokens\n\n`);
184
237
  }
185
238
  main();
package/dist/index.js CHANGED
@@ -44,7 +44,12 @@ async function queryCodex(prompt) {
44
44
  catch {
45
45
  response = '';
46
46
  }
47
- return { model: 'Codex', response: response || '(empty response)' };
47
+ const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
48
+ return {
49
+ model: 'Codex',
50
+ response: response || '(empty response)',
51
+ tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
52
+ };
48
53
  }
49
54
  catch (err) {
50
55
  const message = err instanceof Error ? err.message : String(err);
@@ -59,7 +64,7 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
59
64
  method: 'POST',
60
65
  headers: {
61
66
  'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
62
- 'HTTP-Referer': 'https://hotairbag.github.io/smoothie',
67
+ 'HTTP-Referer': 'https://smoothiecode.com',
63
68
  'X-Title': 'Smoothie',
64
69
  'Content-Type': 'application/json',
65
70
  },
@@ -75,7 +80,12 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
75
80
  }
76
81
  const data = (await res.json());
77
82
  const text = data.choices?.[0]?.message?.content ?? 'No response content';
78
- return { model: modelLabel, response: text };
83
+ const usage = data.usage;
84
+ return {
85
+ model: modelLabel,
86
+ response: text,
87
+ tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
88
+ };
79
89
  }
80
90
  catch (err) {
81
91
  const message = err instanceof Error ? err.message : String(err);
@@ -200,21 +210,60 @@ server.tool('smoothie_blend', {
200
210
  startTimes[label] = Date.now();
201
211
  return fn()
202
212
  .then((result) => {
203
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
204
- process.stderr.write(` \u2713 ${label.padEnd(26)} done (${elapsed}s)\n`);
205
- return result;
213
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
214
+ process.stderr.write(` \u2713 ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
215
+ return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
206
216
  })
207
217
  .catch((err) => {
208
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
218
+ const elapsed = ((Date.now() - startTimes[label]) / 1000);
209
219
  const message = err instanceof Error ? err.message : String(err);
210
- process.stderr.write(` \u2717 ${label.padEnd(26)} failed (${elapsed}s)\n`);
211
- return { model: label, response: `Error: ${message}` };
220
+ process.stderr.write(` \u2717 ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
221
+ return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
212
222
  });
213
223
  });
214
224
  const results = await Promise.all(promises);
215
- const judgeNames = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini' };
225
+ const judgeNames = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', cursor: 'Cursor' };
216
226
  const judgeName = judgeNames[platform] || 'the judge';
217
227
  process.stderr.write(`\n \u25C6 All done. Handing to ${judgeName}...\n\n`);
228
+ // Save for share command + append to history
229
+ try {
230
+ const { writeFileSync, appendFileSync } = await import('fs');
231
+ writeFileSync(join(PROJECT_ROOT, '.last-blend.json'), JSON.stringify({ results }, null, 2));
232
+ const entry = {
233
+ ts: new Date().toISOString(),
234
+ type: deep ? 'deep' : 'blend',
235
+ models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
236
+ };
237
+ appendFileSync(join(PROJECT_ROOT, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
238
+ }
239
+ catch { }
240
+ // Submit to leaderboard if opted in
241
+ try {
242
+ const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
243
+ if (cfg.leaderboard && cfg.github) {
244
+ const now = new Date();
245
+ const jan1 = new Date(now.getFullYear(), 0, 1);
246
+ const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
247
+ const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
248
+ const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
249
+ const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
250
+ const blendId = `${cfg.github}-${Date.now()}`;
251
+ await fetch('https://api.smoothiecode.com/api/submit', {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({
255
+ github: cfg.github,
256
+ blend_id: blendId,
257
+ tokens: totalTokens,
258
+ blends: 1,
259
+ models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
260
+ week,
261
+ }),
262
+ signal: AbortSignal.timeout(5000),
263
+ }).catch(() => { });
264
+ }
265
+ }
266
+ catch { }
218
267
  return {
219
268
  content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }],
220
269
  };
@@ -173,9 +173,11 @@ async function cmdPick(apiKey, configPath) {
173
173
  claude: ['anthropic'],
174
174
  codex: ['openai'],
175
175
  gemini: ['google'],
176
+ cursor: [], // Cursor uses various models, nothing to exclude
176
177
  };
177
178
  const excluded = excludePrefixes[platform] || [];
178
179
  topModels = topModels.filter(m => !excluded.some(prefix => m.id.startsWith(prefix + '/')));
180
+ console.log(' \x1b[90mSee trending: https://openrouter.ai/rankings/programming\x1b[0m');
179
181
  // Default selection: first 3
180
182
  const selected = new Set([0, 1, 2]);
181
183
  // Print list with selection markers