smoothie-code 1.1.0 → 2.0.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-review-hook.sh +138 -0
- package/bin/smoothie +221 -16
- package/config.json +3 -1
- package/dist/blend-cli.js +69 -10
- package/dist/index.js +98 -14
- package/dist/review-cli.d.ts +12 -0
- package/dist/review-cli.js +244 -0
- package/dist/select-models.js +3 -1
- package/gemini-review-hook.sh +149 -0
- package/install.sh +142 -23
- package/package.json +12 -1
- package/plan-hook.sh +1 -1
- package/{pr-blend-hook.sh → pr-review-hook.sh} +43 -9
- package/auto-blend-hook.sh +0 -103
- package/banner-v2.svg +0 -307
- package/docs/banner.svg +0 -307
- package/docs/favicon.png +0 -0
- package/docs/favicon.svg +0 -17
- package/docs/index.html +0 -319
- package/icon.png +0 -0
- 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/dist/blend-cli.js
CHANGED
|
@@ -9,15 +9,21 @@
|
|
|
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, mkdirSync } from 'fs';
|
|
13
13
|
import { fileURLToPath } from 'url';
|
|
14
14
|
import { dirname, join } from 'path';
|
|
15
|
+
import { homedir } from 'os';
|
|
15
16
|
import { execFile as execFileCb } from 'child_process';
|
|
16
17
|
import { promisify } from 'util';
|
|
17
18
|
import { createInterface } from 'readline';
|
|
18
19
|
const execFile = promisify(execFileCb);
|
|
19
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
const PROJECT_ROOT = join(__dirname, '..');
|
|
22
|
+
const SMOOTHIE_HOME = join(homedir(), '.smoothie');
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(SMOOTHIE_HOME, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
21
27
|
// ---------------------------------------------------------------------------
|
|
22
28
|
// .env loader
|
|
23
29
|
// ---------------------------------------------------------------------------
|
|
@@ -53,7 +59,12 @@ async function queryCodex(prompt) {
|
|
|
53
59
|
catch {
|
|
54
60
|
response = '';
|
|
55
61
|
}
|
|
56
|
-
|
|
62
|
+
const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
|
|
63
|
+
return {
|
|
64
|
+
model: 'Codex',
|
|
65
|
+
response: response || '(empty response)',
|
|
66
|
+
tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
|
|
67
|
+
};
|
|
57
68
|
}
|
|
58
69
|
catch (err) {
|
|
59
70
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -68,7 +79,7 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
|
|
|
68
79
|
method: 'POST',
|
|
69
80
|
headers: {
|
|
70
81
|
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
71
|
-
'HTTP-Referer': 'https://
|
|
82
|
+
'HTTP-Referer': 'https://smoothiecode.com',
|
|
72
83
|
'X-Title': 'Smoothie',
|
|
73
84
|
'Content-Type': 'application/json',
|
|
74
85
|
},
|
|
@@ -81,7 +92,12 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
|
|
|
81
92
|
clearTimeout(timer);
|
|
82
93
|
const data = (await res.json());
|
|
83
94
|
const text = data.choices?.[0]?.message?.content ?? 'No response content';
|
|
84
|
-
|
|
95
|
+
const usage = data.usage;
|
|
96
|
+
return {
|
|
97
|
+
model: modelLabel,
|
|
98
|
+
response: text,
|
|
99
|
+
tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
|
|
100
|
+
};
|
|
85
101
|
}
|
|
86
102
|
catch (err) {
|
|
87
103
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -166,20 +182,63 @@ async function main() {
|
|
|
166
182
|
startTimes[label] = Date.now();
|
|
167
183
|
return fn()
|
|
168
184
|
.then((result) => {
|
|
169
|
-
const elapsed = ((Date.now() - startTimes[label]) / 1000)
|
|
170
|
-
process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed}s)\n`);
|
|
171
|
-
return result;
|
|
185
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000);
|
|
186
|
+
process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
|
|
187
|
+
return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
|
|
172
188
|
})
|
|
173
189
|
.catch((err) => {
|
|
174
|
-
const elapsed = ((Date.now() - startTimes[label]) / 1000)
|
|
190
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000);
|
|
175
191
|
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}
|
|
192
|
+
process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
|
|
193
|
+
return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
|
|
178
194
|
});
|
|
179
195
|
});
|
|
180
196
|
const results = await Promise.all(promises);
|
|
181
197
|
process.stderr.write('\n ◆ All done.\n\n');
|
|
182
198
|
// Output JSON to stdout (for hook consumption)
|
|
183
199
|
process.stdout.write(JSON.stringify({ results }, null, 2));
|
|
200
|
+
// Save for share command + append to history
|
|
201
|
+
try {
|
|
202
|
+
const { appendFileSync } = await import('fs');
|
|
203
|
+
writeFileSync(join(SMOOTHIE_HOME, '.last-blend.json'), JSON.stringify({ results }, null, 2));
|
|
204
|
+
const entry = {
|
|
205
|
+
ts: new Date().toISOString(),
|
|
206
|
+
type: deep ? 'deep' : 'blend',
|
|
207
|
+
models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
|
|
208
|
+
};
|
|
209
|
+
appendFileSync(join(SMOOTHIE_HOME, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
// Submit to leaderboard if opted in
|
|
213
|
+
try {
|
|
214
|
+
const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
|
|
215
|
+
if (cfg.leaderboard && cfg.github) {
|
|
216
|
+
const now = new Date();
|
|
217
|
+
const jan1 = new Date(now.getFullYear(), 0, 1);
|
|
218
|
+
const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
|
|
219
|
+
const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
|
|
220
|
+
const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
|
|
221
|
+
const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
|
|
222
|
+
const blendId = `${cfg.github}-${Date.now()}`;
|
|
223
|
+
await fetch('https://api.smoothiecode.com/api/submit', {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
github: cfg.github,
|
|
228
|
+
blend_id: blendId,
|
|
229
|
+
tokens: totalTokens,
|
|
230
|
+
blends: 1,
|
|
231
|
+
models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
|
|
232
|
+
week,
|
|
233
|
+
}),
|
|
234
|
+
signal: AbortSignal.timeout(5000),
|
|
235
|
+
}).catch(() => { });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { }
|
|
239
|
+
const totalTime = Math.max(...results.map(r => r.elapsed_s || 0));
|
|
240
|
+
const totalTokens = results.reduce((sum, r) => sum + (r.tokens?.total || 0), 0);
|
|
241
|
+
const responded = results.filter(r => !r.response.startsWith('Error:')).length;
|
|
242
|
+
process.stderr.write(` ${responded}/${results.length} models · ${totalTime.toFixed(1)}s · ${totalTokens} tokens\n\n`);
|
|
184
243
|
}
|
|
185
244
|
main();
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync } from 'fs';
|
|
1
|
+
import { readFileSync, mkdirSync } from 'fs';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
4
5
|
import { execFile as execFileCb, execFileSync } from 'child_process';
|
|
5
6
|
import { promisify } from 'util';
|
|
6
7
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -9,6 +10,17 @@ import { z } from 'zod';
|
|
|
9
10
|
const execFile = promisify(execFileCb);
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const PROJECT_ROOT = join(__dirname, '..');
|
|
13
|
+
const SMOOTHIE_HOME = join(homedir(), '.smoothie');
|
|
14
|
+
try {
|
|
15
|
+
mkdirSync(SMOOTHIE_HOME, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
// Read version from package.json
|
|
19
|
+
let CURRENT_VERSION = '0.0.0';
|
|
20
|
+
try {
|
|
21
|
+
CURRENT_VERSION = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')).version;
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
12
24
|
// ---------------------------------------------------------------------------
|
|
13
25
|
// .env loader (no dotenv dependency)
|
|
14
26
|
// ---------------------------------------------------------------------------
|
|
@@ -44,7 +56,12 @@ async function queryCodex(prompt) {
|
|
|
44
56
|
catch {
|
|
45
57
|
response = '';
|
|
46
58
|
}
|
|
47
|
-
|
|
59
|
+
const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
|
|
60
|
+
return {
|
|
61
|
+
model: 'Codex',
|
|
62
|
+
response: response || '(empty response)',
|
|
63
|
+
tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
|
|
64
|
+
};
|
|
48
65
|
}
|
|
49
66
|
catch (err) {
|
|
50
67
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -59,7 +76,7 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
|
|
|
59
76
|
method: 'POST',
|
|
60
77
|
headers: {
|
|
61
78
|
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
62
|
-
'HTTP-Referer': 'https://
|
|
79
|
+
'HTTP-Referer': 'https://smoothiecode.com',
|
|
63
80
|
'X-Title': 'Smoothie',
|
|
64
81
|
'Content-Type': 'application/json',
|
|
65
82
|
},
|
|
@@ -75,7 +92,12 @@ async function queryOpenRouter(prompt, modelId, modelLabel) {
|
|
|
75
92
|
}
|
|
76
93
|
const data = (await res.json());
|
|
77
94
|
const text = data.choices?.[0]?.message?.content ?? 'No response content';
|
|
78
|
-
|
|
95
|
+
const usage = data.usage;
|
|
96
|
+
return {
|
|
97
|
+
model: modelLabel,
|
|
98
|
+
response: text,
|
|
99
|
+
tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
|
|
100
|
+
};
|
|
79
101
|
}
|
|
80
102
|
catch (err) {
|
|
81
103
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -154,7 +176,7 @@ function buildDeepContext(prompt) {
|
|
|
154
176
|
// MCP Server
|
|
155
177
|
// ---------------------------------------------------------------------------
|
|
156
178
|
const server = new McpServer({ name: 'smoothie', version: '1.0.0' });
|
|
157
|
-
server.tool('
|
|
179
|
+
server.tool('smoothie_review', {
|
|
158
180
|
prompt: z.string().describe('The prompt to send to all models'),
|
|
159
181
|
deep: z.boolean().optional().describe('Full context mode with project files and git diff'),
|
|
160
182
|
}, async ({ prompt, deep }) => {
|
|
@@ -189,7 +211,7 @@ server.tool('smoothie_blend', {
|
|
|
189
211
|
});
|
|
190
212
|
}
|
|
191
213
|
// Print initial progress
|
|
192
|
-
process.stderr.write('\n\u{1F9C3} Smoothie
|
|
214
|
+
process.stderr.write('\n\u{1F9C3} Smoothie reviewing...\n\n');
|
|
193
215
|
for (const { label } of models) {
|
|
194
216
|
process.stderr.write(` \u23F3 ${label.padEnd(26)} waiting...\n`);
|
|
195
217
|
}
|
|
@@ -200,23 +222,85 @@ server.tool('smoothie_blend', {
|
|
|
200
222
|
startTimes[label] = Date.now();
|
|
201
223
|
return fn()
|
|
202
224
|
.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;
|
|
225
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000);
|
|
226
|
+
process.stderr.write(` \u2713 ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
|
|
227
|
+
return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
|
|
206
228
|
})
|
|
207
229
|
.catch((err) => {
|
|
208
|
-
const elapsed = ((Date.now() - startTimes[label]) / 1000)
|
|
230
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000);
|
|
209
231
|
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}
|
|
232
|
+
process.stderr.write(` \u2717 ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
|
|
233
|
+
return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
|
|
212
234
|
});
|
|
213
235
|
});
|
|
214
236
|
const results = await Promise.all(promises);
|
|
215
|
-
const judgeNames = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini' };
|
|
237
|
+
const judgeNames = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', cursor: 'Cursor' };
|
|
216
238
|
const judgeName = judgeNames[platform] || 'the judge';
|
|
217
239
|
process.stderr.write(`\n \u25C6 All done. Handing to ${judgeName}...\n\n`);
|
|
240
|
+
// Save for share command + append to history
|
|
241
|
+
try {
|
|
242
|
+
const { writeFileSync, appendFileSync } = await import('fs');
|
|
243
|
+
writeFileSync(join(SMOOTHIE_HOME, '.last-review.json'), JSON.stringify({ results }, null, 2));
|
|
244
|
+
const entry = {
|
|
245
|
+
ts: new Date().toISOString(),
|
|
246
|
+
type: deep ? 'deep' : 'review',
|
|
247
|
+
models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
|
|
248
|
+
};
|
|
249
|
+
appendFileSync(join(SMOOTHIE_HOME, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
|
|
250
|
+
}
|
|
251
|
+
catch { }
|
|
252
|
+
// Submit to leaderboard if opted in
|
|
253
|
+
try {
|
|
254
|
+
const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
|
|
255
|
+
if (cfg.leaderboard && cfg.github) {
|
|
256
|
+
const now = new Date();
|
|
257
|
+
const jan1 = new Date(now.getFullYear(), 0, 1);
|
|
258
|
+
const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
|
|
259
|
+
const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
|
|
260
|
+
const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
|
|
261
|
+
const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
|
|
262
|
+
const reviewId = `${cfg.github}-${Date.now()}`;
|
|
263
|
+
await fetch('https://api.smoothiecode.com/api/submit', {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
body: JSON.stringify({
|
|
267
|
+
github: cfg.github,
|
|
268
|
+
review_id: reviewId,
|
|
269
|
+
tokens: totalTokens,
|
|
270
|
+
blends: 1,
|
|
271
|
+
models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
|
|
272
|
+
week,
|
|
273
|
+
}),
|
|
274
|
+
signal: AbortSignal.timeout(5000),
|
|
275
|
+
}).catch(() => { });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch { }
|
|
279
|
+
// Weekly update check (non-blocking)
|
|
280
|
+
let updateNote = '';
|
|
281
|
+
try {
|
|
282
|
+
const checkFile = join(SMOOTHIE_HOME, '.update-check');
|
|
283
|
+
let shouldCheck = true;
|
|
284
|
+
try {
|
|
285
|
+
const last = readFileSync(checkFile, 'utf8').trim();
|
|
286
|
+
if (Date.now() - parseInt(last) < 7 * 24 * 60 * 60 * 1000)
|
|
287
|
+
shouldCheck = false;
|
|
288
|
+
}
|
|
289
|
+
catch { }
|
|
290
|
+
if (shouldCheck) {
|
|
291
|
+
const { writeFileSync: wfs } = await import('fs');
|
|
292
|
+
wfs(checkFile, String(Date.now()));
|
|
293
|
+
const res = await fetch('https://registry.npmjs.org/smoothie-code/latest', { signal: AbortSignal.timeout(3000) });
|
|
294
|
+
const { version: latest } = (await res.json());
|
|
295
|
+
const current = CURRENT_VERSION;
|
|
296
|
+
if (latest && latest !== current) {
|
|
297
|
+
updateNote = `\n\n⬆ Smoothie update available: ${current} → ${latest} — run: npx smoothie-code`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch { }
|
|
218
302
|
return {
|
|
219
|
-
content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }],
|
|
303
|
+
content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) + updateNote }],
|
|
220
304
|
};
|
|
221
305
|
});
|
|
222
306
|
server.tool('smoothie_estimate', {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* review-cli.ts — Standalone blend runner for hooks.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node dist/review-cli.js "Review this plan: ..."
|
|
7
|
+
* echo "plan text" | node dist/review-cli.js
|
|
8
|
+
*
|
|
9
|
+
* Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
|
|
10
|
+
* Progress goes to stderr so it doesn't interfere with hook JSON output.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* review-cli.ts — Standalone blend runner for hooks.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node dist/review-cli.js "Review this plan: ..."
|
|
7
|
+
* echo "plan text" | node dist/review-cli.js
|
|
8
|
+
*
|
|
9
|
+
* Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
|
|
10
|
+
* Progress goes to stderr so it doesn't interfere with hook JSON output.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { dirname, join } from 'path';
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
import { execFile as execFileCb } from 'child_process';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
import { createInterface } from 'readline';
|
|
19
|
+
const execFile = promisify(execFileCb);
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PROJECT_ROOT = join(__dirname, '..');
|
|
22
|
+
const SMOOTHIE_HOME = join(homedir(), '.smoothie');
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(SMOOTHIE_HOME, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// .env loader
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
function loadEnv() {
|
|
31
|
+
try {
|
|
32
|
+
const env = readFileSync(join(PROJECT_ROOT, '.env'), 'utf8');
|
|
33
|
+
for (const line of env.split('\n')) {
|
|
34
|
+
const [key, ...val] = line.split('=');
|
|
35
|
+
if (key && val.length)
|
|
36
|
+
process.env[key.trim()] = val.join('=').trim();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// no .env
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
loadEnv();
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Model queries (same as index.ts)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
async function queryCodex(prompt) {
|
|
48
|
+
try {
|
|
49
|
+
const tmpFile = join(PROJECT_ROOT, `.codex-out-${Date.now()}.txt`);
|
|
50
|
+
await execFile('codex', ['exec', '--full-auto', '-o', tmpFile, prompt], {
|
|
51
|
+
timeout: 0,
|
|
52
|
+
});
|
|
53
|
+
let response;
|
|
54
|
+
try {
|
|
55
|
+
response = readFileSync(tmpFile, 'utf8').trim();
|
|
56
|
+
const { unlinkSync } = await import('fs');
|
|
57
|
+
unlinkSync(tmpFile);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
response = '';
|
|
61
|
+
}
|
|
62
|
+
const estimatedTokens = Math.ceil(prompt.length / 3) + Math.ceil(response.length / 3);
|
|
63
|
+
return {
|
|
64
|
+
model: 'Codex',
|
|
65
|
+
response: response || '(empty response)',
|
|
66
|
+
tokens: { prompt: Math.ceil(prompt.length / 3), completion: Math.ceil(response.length / 3), total: estimatedTokens },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
71
|
+
return { model: 'Codex', response: `Error: ${message}` };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function queryOpenRouter(prompt, modelId, modelLabel) {
|
|
75
|
+
try {
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const timer = setTimeout(() => controller.abort(), 60_000);
|
|
78
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
82
|
+
'HTTP-Referer': 'https://smoothiecode.com',
|
|
83
|
+
'X-Title': 'Smoothie',
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
model: modelId,
|
|
88
|
+
messages: [{ role: 'user', content: prompt }],
|
|
89
|
+
}),
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
const data = (await res.json());
|
|
94
|
+
const text = data.choices?.[0]?.message?.content ?? 'No response content';
|
|
95
|
+
const usage = data.usage;
|
|
96
|
+
return {
|
|
97
|
+
model: modelLabel,
|
|
98
|
+
response: text,
|
|
99
|
+
tokens: usage ? { prompt: usage.prompt_tokens || 0, completion: usage.completion_tokens || 0, total: usage.total_tokens || 0 } : undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
104
|
+
return { model: modelLabel, response: `Error: ${message}` };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Read prompt from arg or stdin
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
async function getPrompt() {
|
|
111
|
+
if (process.argv[2])
|
|
112
|
+
return process.argv[2];
|
|
113
|
+
// Read from stdin
|
|
114
|
+
const rl = createInterface({ input: process.stdin });
|
|
115
|
+
const lines = [];
|
|
116
|
+
for await (const line of rl) {
|
|
117
|
+
lines.push(line);
|
|
118
|
+
}
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Main
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
async function main() {
|
|
125
|
+
const args = process.argv.slice(2);
|
|
126
|
+
const deep = args.includes('--deep');
|
|
127
|
+
const filteredArgs = args.filter(a => a !== '--deep');
|
|
128
|
+
// Temporarily override argv for getPrompt
|
|
129
|
+
process.argv = [process.argv[0], process.argv[1], ...filteredArgs];
|
|
130
|
+
const prompt = await getPrompt();
|
|
131
|
+
if (!prompt.trim()) {
|
|
132
|
+
process.stderr.write('review-cli: no prompt provided\n');
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
let finalPrompt = prompt;
|
|
136
|
+
if (deep) {
|
|
137
|
+
// Read context file
|
|
138
|
+
for (const name of ['GEMINI.md', 'CLAUDE.md', 'AGENTS.md']) {
|
|
139
|
+
try {
|
|
140
|
+
const content = readFileSync(join(process.cwd(), name), 'utf8');
|
|
141
|
+
if (content.trim()) {
|
|
142
|
+
finalPrompt = `## Context File\n${content}\n\n## Prompt\n${prompt}`;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// file not found, try next
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Add git diff
|
|
151
|
+
try {
|
|
152
|
+
const { execFileSync } = await import('child_process');
|
|
153
|
+
const diff = execFileSync('git', ['diff', 'HEAD~3'], { encoding: 'utf8', maxBuffer: 100 * 1024, timeout: 10000 });
|
|
154
|
+
if (diff)
|
|
155
|
+
finalPrompt += `\n\n## Recent Git Diff\n${diff.slice(0, 40000)}`;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// no git diff available
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let config;
|
|
162
|
+
try {
|
|
163
|
+
config = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
config = { openrouter_models: [] };
|
|
167
|
+
}
|
|
168
|
+
const models = [
|
|
169
|
+
{ fn: () => queryCodex(finalPrompt), label: 'Codex' },
|
|
170
|
+
...config.openrouter_models.map((m) => ({
|
|
171
|
+
fn: () => queryOpenRouter(finalPrompt, m.id, m.label),
|
|
172
|
+
label: m.label,
|
|
173
|
+
})),
|
|
174
|
+
];
|
|
175
|
+
process.stderr.write('\n🧃 Smoothie reviewing...\n\n');
|
|
176
|
+
for (const { label } of models) {
|
|
177
|
+
process.stderr.write(` ⏳ ${label.padEnd(26)} waiting...\n`);
|
|
178
|
+
}
|
|
179
|
+
process.stderr.write('\n');
|
|
180
|
+
const startTimes = {};
|
|
181
|
+
const promises = models.map(({ fn, label }) => {
|
|
182
|
+
startTimes[label] = Date.now();
|
|
183
|
+
return fn()
|
|
184
|
+
.then((result) => {
|
|
185
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000);
|
|
186
|
+
process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed.toFixed(1)}s)\n`);
|
|
187
|
+
return { ...result, elapsed_s: parseFloat(elapsed.toFixed(1)) };
|
|
188
|
+
})
|
|
189
|
+
.catch((err) => {
|
|
190
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000);
|
|
191
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
+
process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed.toFixed(1)}s)\n`);
|
|
193
|
+
return { model: label, response: `Error: ${message}`, elapsed_s: parseFloat(elapsed.toFixed(1)) };
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
const results = await Promise.all(promises);
|
|
197
|
+
process.stderr.write('\n ◆ All done.\n\n');
|
|
198
|
+
// Output JSON to stdout (for hook consumption)
|
|
199
|
+
process.stdout.write(JSON.stringify({ results }, null, 2));
|
|
200
|
+
// Save for share command + append to history
|
|
201
|
+
try {
|
|
202
|
+
const { appendFileSync } = await import('fs');
|
|
203
|
+
writeFileSync(join(SMOOTHIE_HOME, '.last-review.json'), JSON.stringify({ results }, null, 2));
|
|
204
|
+
const entry = {
|
|
205
|
+
ts: new Date().toISOString(),
|
|
206
|
+
type: deep ? 'deep' : 'review',
|
|
207
|
+
models: results.map(r => ({ model: r.model, elapsed_s: r.elapsed_s, tokens: r.tokens, error: r.response.startsWith('Error:') })),
|
|
208
|
+
};
|
|
209
|
+
appendFileSync(join(SMOOTHIE_HOME, '.smoothie-history.jsonl'), JSON.stringify(entry) + '\n');
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
// Submit to leaderboard if opted in
|
|
213
|
+
try {
|
|
214
|
+
const cfg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'));
|
|
215
|
+
if (cfg.leaderboard && cfg.github) {
|
|
216
|
+
const now = new Date();
|
|
217
|
+
const jan1 = new Date(now.getFullYear(), 0, 1);
|
|
218
|
+
const days = Math.floor((now.getTime() - jan1.getTime()) / 86400000);
|
|
219
|
+
const weekNum = Math.ceil((days + jan1.getDay() + 1) / 7);
|
|
220
|
+
const week = `${now.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
|
|
221
|
+
const totalTokens = results.reduce((s, r) => s + (r.tokens?.total || 0), 0);
|
|
222
|
+
const reviewId = `${cfg.github}-${Date.now()}`;
|
|
223
|
+
await fetch('https://api.smoothiecode.com/api/submit', {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
github: cfg.github,
|
|
228
|
+
review_id: reviewId,
|
|
229
|
+
tokens: totalTokens,
|
|
230
|
+
blends: 1,
|
|
231
|
+
models: results.map(r => ({ model: r.model, tokens: r.tokens?.total || 0 })),
|
|
232
|
+
week,
|
|
233
|
+
}),
|
|
234
|
+
signal: AbortSignal.timeout(5000),
|
|
235
|
+
}).catch(() => { });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { }
|
|
239
|
+
const totalTime = Math.max(...results.map(r => r.elapsed_s || 0));
|
|
240
|
+
const totalTokens = results.reduce((sum, r) => sum + (r.tokens?.total || 0), 0);
|
|
241
|
+
const responded = results.filter(r => !r.response.startsWith('Error:')).length;
|
|
242
|
+
process.stderr.write(` ${responded}/${results.length} models · ${totalTime.toFixed(1)}s · ${totalTokens} tokens\n\n`);
|
|
243
|
+
}
|
|
244
|
+
main();
|
package/dist/select-models.js
CHANGED
|
@@ -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
|
|
@@ -232,7 +234,7 @@ async function cmdPick(apiKey, configPath) {
|
|
|
232
234
|
finalSelection.push(p);
|
|
233
235
|
}
|
|
234
236
|
}
|
|
235
|
-
// Preserve existing config fields (like
|
|
237
|
+
// Preserve existing config fields (like auto_review)
|
|
236
238
|
const existing = loadConfig(configPath);
|
|
237
239
|
existing.openrouter_models = finalSelection;
|
|
238
240
|
saveConfig(configPath, existing);
|