smoothie-code 1.0.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 +39 -57
- package/auto-blend-hook.sh +45 -1
- package/bin/smoothie +211 -0
- package/config.json +3 -1
- package/dist/blend-cli.js +64 -11
- package/dist/index.js +60 -11
- package/dist/select-models.js +2 -0
- package/install.sh +120 -9
- package/package.json +11 -1
- package/pr-blend-hook.sh +45 -2
- package/banner-v2.svg +0 -307
- package/docs/banner.svg +0 -307
- package/docs/favicon.svg +0 -17
- package/docs/index.html +0 -306
- package/icon.svg +0 -17
- package/src/blend-cli.ts +0 -219
- package/src/index.ts +0 -367
- package/src/select-models.ts +0 -318
- package/tsconfig.json +0 -14
package/README.md
CHANGED
|
@@ -1,82 +1,64 @@
|
|
|
1
1
|
# Smoothie
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
+
npx smoothie-code
|
|
17
11
|
```
|
|
18
12
|
|
|
19
|
-
|
|
13
|
+
Works with **Claude Code**, **Gemini CLI**, **Codex CLI**, and **Cursor**.
|
|
20
14
|
|
|
21
|
-
|
|
15
|
+
## Features
|
|
22
16
|
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
```
|
|
27
|
-
/smoothie <your problem or question>
|
|
28
|
-
```
|
|
24
|
+
## Platform support
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
34
|
+
## CLI
|
|
39
35
|
|
|
40
|
-
### Manage models
|
|
41
36
|
```bash
|
|
42
|
-
smoothie models #
|
|
43
|
-
smoothie
|
|
44
|
-
smoothie
|
|
45
|
-
smoothie
|
|
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
|
|
50
|
+
Your IDE (Claude/Gemini/Cursor)
|
|
53
51
|
|
|
|
54
|
-
|-- /smoothie
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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)
|
package/auto-blend-hook.sh
CHANGED
|
@@ -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)+'×='+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
|
|
@@ -76,7 +106,21 @@ CONTEXT="🧃 Smoothie auto-blend results — multiple models reviewed this plan
|
|
|
76
106
|
|
|
77
107
|
$BLEND_RESULTS
|
|
78
108
|
|
|
79
|
-
|
|
109
|
+
IMPORTANT — You must do TWO things:
|
|
110
|
+
|
|
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):
|
|
114
|
+
|
|
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")
|
|
122
|
+
|
|
123
|
+
2. THEN proceed with implementation as planned, incorporating valid Smoothie suggestions as you go. Do NOT stop to revise — start coding immediately."
|
|
80
124
|
|
|
81
125
|
# Return: allow ExitPlanMode but inject blend results
|
|
82
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) + '×=' + 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
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';
|
|
@@ -42,7 +42,7 @@ async function queryCodex(prompt) {
|
|
|
42
42
|
try {
|
|
43
43
|
const tmpFile = join(PROJECT_ROOT, `.codex-out-${Date.now()}.txt`);
|
|
44
44
|
await execFile('codex', ['exec', '--full-auto', '-o', tmpFile, prompt], {
|
|
45
|
-
timeout:
|
|
45
|
+
timeout: 0,
|
|
46
46
|
});
|
|
47
47
|
let response;
|
|
48
48
|
try {
|
|
@@ -53,7 +53,12 @@ async function queryCodex(prompt) {
|
|
|
53
53
|
catch {
|
|
54
54
|
response = '';
|
|
55
55
|
}
|
|
56
|
-
|
|
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://
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
@@ -33,7 +33,7 @@ async function queryCodex(prompt) {
|
|
|
33
33
|
try {
|
|
34
34
|
const tmpFile = join(PROJECT_ROOT, `.codex-out-${Date.now()}.txt`);
|
|
35
35
|
await execFile('codex', ['exec', '--full-auto', '-o', tmpFile, prompt], {
|
|
36
|
-
timeout:
|
|
36
|
+
timeout: 0,
|
|
37
37
|
});
|
|
38
38
|
let response;
|
|
39
39
|
try {
|
|
@@ -44,7 +44,12 @@ async function queryCodex(prompt) {
|
|
|
44
44
|
catch {
|
|
45
45
|
response = '';
|
|
46
46
|
}
|
|
47
|
-
|
|
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://
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
};
|