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/icon.svg DELETED
@@ -1,17 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
- <rect width="64" height="64" rx="14" fill="#0d1117"/>
3
- <g transform="translate(12, 10)" stroke="#f0f6fc" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none">
4
- <!-- Straw (inside viewBox now) -->
5
- <line x1="24" y1="8" x2="32" y2="2"/>
6
- <line x1="32" y1="2" x2="36" y2="2"/>
7
- <!-- Dome -->
8
- <path d="M 8 20 C 8 12 14 6 20 6 C 26 6 32 12 32 20"/>
9
- <!-- Lid -->
10
- <path d="M 2 20 L 38 20 C 38 20 38 24 20 24 C 2 24 2 20 2 20"/>
11
- <!-- Cup body -->
12
- <path d="M 6 24 L 10 46 L 30 46 L 34 24"/>
13
- <!-- Drips -->
14
- <path d="M 13 24 C 13 28 17 28 17 24" opacity="0.4" stroke-width="1.8"/>
15
- <path d="M 23 24 C 23 28 27 28 27 24" opacity="0.4" stroke-width="1.8"/>
16
- </g>
17
- </svg>
package/src/blend-cli.ts DELETED
@@ -1,219 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * blend-cli.ts — Standalone blend runner for hooks.
5
- *
6
- * Usage:
7
- * node dist/blend-cli.js "Review this plan: ..."
8
- * echo "plan text" | node dist/blend-cli.js
9
- *
10
- * Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
11
- * Progress goes to stderr so it doesn't interfere with hook JSON output.
12
- */
13
-
14
- import { readFileSync } from 'fs';
15
- import { fileURLToPath } from 'url';
16
- import { dirname, join } from 'path';
17
- import { execFile as execFileCb } from 'child_process';
18
- import { promisify } from 'util';
19
- import { createInterface } from 'readline';
20
-
21
- const execFile = promisify(execFileCb);
22
- const __dirname = dirname(fileURLToPath(import.meta.url));
23
- const PROJECT_ROOT = join(__dirname, '..');
24
-
25
- // ---------------------------------------------------------------------------
26
- // Types
27
- // ---------------------------------------------------------------------------
28
-
29
- interface Config {
30
- openrouter_models: Array<{ id: string; label: string }>;
31
- auto_blend?: boolean;
32
- }
33
-
34
- interface ModelResult {
35
- model: string;
36
- response: string;
37
- }
38
-
39
- interface OpenRouterResponse {
40
- choices?: Array<{ message: { content: string } }>;
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // .env loader
45
- // ---------------------------------------------------------------------------
46
- function loadEnv(): void {
47
- try {
48
- const env = readFileSync(join(PROJECT_ROOT, '.env'), 'utf8');
49
- for (const line of env.split('\n')) {
50
- const [key, ...val] = line.split('=');
51
- if (key && val.length) process.env[key.trim()] = val.join('=').trim();
52
- }
53
- } catch {
54
- // no .env
55
- }
56
- }
57
- loadEnv();
58
-
59
- // ---------------------------------------------------------------------------
60
- // Model queries (same as index.ts)
61
- // ---------------------------------------------------------------------------
62
-
63
- async function queryCodex(prompt: string): Promise<ModelResult> {
64
- try {
65
- const tmpFile = join(PROJECT_ROOT, `.codex-out-${Date.now()}.txt`);
66
- await execFile('codex', ['exec', '--full-auto', '-o', tmpFile, prompt], {
67
- timeout: 0,
68
- });
69
- let response: string;
70
- try {
71
- response = readFileSync(tmpFile, 'utf8').trim();
72
- const { unlinkSync } = await import('fs');
73
- unlinkSync(tmpFile);
74
- } catch {
75
- response = '';
76
- }
77
- return { model: 'Codex', response: response || '(empty response)' };
78
- } catch (err: unknown) {
79
- const message = err instanceof Error ? err.message : String(err);
80
- return { model: 'Codex', response: `Error: ${message}` };
81
- }
82
- }
83
-
84
- async function queryOpenRouter(
85
- prompt: string,
86
- modelId: string,
87
- modelLabel: string,
88
- ): Promise<ModelResult> {
89
- try {
90
- const controller = new AbortController();
91
- const timer = setTimeout(() => controller.abort(), 60_000);
92
- const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
93
- method: 'POST',
94
- headers: {
95
- 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
96
- 'HTTP-Referer': 'https://hotairbag.github.io/smoothie',
97
- 'X-Title': 'Smoothie',
98
- 'Content-Type': 'application/json',
99
- },
100
- body: JSON.stringify({
101
- model: modelId,
102
- messages: [{ role: 'user', content: prompt }],
103
- }),
104
- signal: controller.signal,
105
- });
106
- clearTimeout(timer);
107
- const data = (await res.json()) as OpenRouterResponse;
108
- const text = data.choices?.[0]?.message?.content ?? 'No response content';
109
- return { model: modelLabel, response: text };
110
- } catch (err: unknown) {
111
- const message = err instanceof Error ? err.message : String(err);
112
- return { model: modelLabel, response: `Error: ${message}` };
113
- }
114
- }
115
-
116
- // ---------------------------------------------------------------------------
117
- // Read prompt from arg or stdin
118
- // ---------------------------------------------------------------------------
119
-
120
- async function getPrompt(): Promise<string> {
121
- if (process.argv[2]) return process.argv[2];
122
-
123
- // Read from stdin
124
- const rl = createInterface({ input: process.stdin });
125
- const lines: string[] = [];
126
- for await (const line of rl) {
127
- lines.push(line);
128
- }
129
- return lines.join('\n');
130
- }
131
-
132
- // ---------------------------------------------------------------------------
133
- // Main
134
- // ---------------------------------------------------------------------------
135
-
136
- async function main(): Promise<void> {
137
- const args = process.argv.slice(2);
138
- const deep = args.includes('--deep');
139
- const filteredArgs = args.filter(a => a !== '--deep');
140
- // Temporarily override argv for getPrompt
141
- process.argv = [process.argv[0], process.argv[1], ...filteredArgs];
142
- const prompt = await getPrompt();
143
- if (!prompt.trim()) {
144
- process.stderr.write('blend-cli: no prompt provided\n');
145
- process.exit(1);
146
- }
147
-
148
- let finalPrompt = prompt;
149
- if (deep) {
150
- // Read context file
151
- for (const name of ['GEMINI.md', 'CLAUDE.md', 'AGENTS.md']) {
152
- try {
153
- const content = readFileSync(join(process.cwd(), name), 'utf8');
154
- if (content.trim()) {
155
- finalPrompt = `## Context File\n${content}\n\n## Prompt\n${prompt}`;
156
- break;
157
- }
158
- } catch {
159
- // file not found, try next
160
- }
161
- }
162
- // Add git diff
163
- try {
164
- const { execFileSync } = await import('child_process');
165
- const diff = execFileSync('git', ['diff', 'HEAD~3'], { encoding: 'utf8', maxBuffer: 100 * 1024, timeout: 10000 });
166
- if (diff) finalPrompt += `\n\n## Recent Git Diff\n${diff.slice(0, 40000)}`;
167
- } catch {
168
- // no git diff available
169
- }
170
- }
171
-
172
- let config: Config;
173
- try {
174
- config = JSON.parse(
175
- readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'),
176
- ) as Config;
177
- } catch {
178
- config = { openrouter_models: [] };
179
- }
180
-
181
- const models: Array<{ fn: () => Promise<ModelResult>; label: string }> = [
182
- { fn: () => queryCodex(finalPrompt), label: 'Codex' },
183
- ...config.openrouter_models.map((m) => ({
184
- fn: () => queryOpenRouter(finalPrompt, m.id, m.label),
185
- label: m.label,
186
- })),
187
- ];
188
-
189
- process.stderr.write('\n🧃 Smoothie blending...\n\n');
190
- for (const { label } of models) {
191
- process.stderr.write(` ⏳ ${label.padEnd(26)} waiting...\n`);
192
- }
193
- process.stderr.write('\n');
194
-
195
- const startTimes: Record<string, number> = {};
196
- const promises = models.map(({ fn, label }) => {
197
- startTimes[label] = Date.now();
198
- return fn()
199
- .then((result) => {
200
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
201
- process.stderr.write(` ✓ ${label.padEnd(26)} done (${elapsed}s)\n`);
202
- return result;
203
- })
204
- .catch((err: unknown) => {
205
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
206
- const message = err instanceof Error ? err.message : String(err);
207
- process.stderr.write(` ✗ ${label.padEnd(26)} failed (${elapsed}s)\n`);
208
- return { model: label, response: `Error: ${message}` };
209
- });
210
- });
211
-
212
- const results = await Promise.all(promises);
213
- process.stderr.write('\n ◆ All done.\n\n');
214
-
215
- // Output JSON to stdout (for hook consumption)
216
- process.stdout.write(JSON.stringify({ results }, null, 2));
217
- }
218
-
219
- main();
package/src/index.ts DELETED
@@ -1,367 +0,0 @@
1
- import { readFileSync } from 'fs';
2
- import { fileURLToPath } from 'url';
3
- import { dirname, join } from 'path';
4
- import { execFile as execFileCb, execFileSync } from 'child_process';
5
- import { promisify } from 'util';
6
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
- import { z } from 'zod';
9
-
10
- const execFile = promisify(execFileCb);
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
- const PROJECT_ROOT = join(__dirname, '..');
13
-
14
- // ---------------------------------------------------------------------------
15
- // Types
16
- // ---------------------------------------------------------------------------
17
-
18
- interface OpenRouterModel {
19
- id: string;
20
- label: string;
21
- }
22
-
23
- interface Config {
24
- openrouter_models: OpenRouterModel[];
25
- }
26
-
27
- interface ModelResult {
28
- model: string;
29
- response: string;
30
- }
31
-
32
- interface ModelEntry {
33
- fn: () => Promise<ModelResult>;
34
- label: string;
35
- }
36
-
37
- interface OpenRouterMessage {
38
- role: string;
39
- content: string;
40
- }
41
-
42
- interface OpenRouterChoice {
43
- message: OpenRouterMessage;
44
- }
45
-
46
- interface OpenRouterResponse {
47
- choices?: OpenRouterChoice[];
48
- }
49
-
50
- // ---------------------------------------------------------------------------
51
- // .env loader (no dotenv dependency)
52
- // ---------------------------------------------------------------------------
53
- function loadEnv(): void {
54
- try {
55
- const env = readFileSync(join(PROJECT_ROOT, '.env'), 'utf8');
56
- for (const line of env.split('\n')) {
57
- const [key, ...val] = line.split('=');
58
- if (key && val.length) process.env[key.trim()] = val.join('=').trim();
59
- }
60
- } catch {
61
- // .env file not found or unreadable — that's fine
62
- }
63
- }
64
- loadEnv();
65
-
66
- // ---------------------------------------------------------------------------
67
- // Model query helpers
68
- // ---------------------------------------------------------------------------
69
-
70
- async function queryCodex(prompt: string): Promise<ModelResult> {
71
- try {
72
- const tmpFile = join(PROJECT_ROOT, `.codex-out-${Date.now()}.txt`);
73
- await execFile('codex', ['exec', '--full-auto', '-o', tmpFile, prompt], {
74
- timeout: 0,
75
- });
76
- let response: string;
77
- try {
78
- response = readFileSync(tmpFile, 'utf8').trim();
79
- const { unlinkSync } = await import('fs');
80
- unlinkSync(tmpFile);
81
- } catch {
82
- response = '';
83
- }
84
- return { model: 'Codex', response: response || '(empty response)' };
85
- } catch (err: unknown) {
86
- const message = err instanceof Error ? err.message : String(err);
87
- return { model: 'Codex', response: `Error: ${message}` };
88
- }
89
- }
90
-
91
- async function queryOpenRouter(
92
- prompt: string,
93
- modelId: string,
94
- modelLabel: string,
95
- ): Promise<ModelResult> {
96
- try {
97
- const controller = new AbortController();
98
- const timer = setTimeout(() => controller.abort(), 60_000);
99
-
100
- const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
101
- method: 'POST',
102
- headers: {
103
- 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
104
- 'HTTP-Referer': 'https://hotairbag.github.io/smoothie',
105
- 'X-Title': 'Smoothie',
106
- 'Content-Type': 'application/json',
107
- },
108
- body: JSON.stringify({
109
- model: modelId,
110
- messages: [{ role: 'user', content: prompt }],
111
- }),
112
- signal: controller.signal,
113
- });
114
-
115
- clearTimeout(timer);
116
-
117
- if (!res.ok) {
118
- return { model: modelLabel, response: `Error: HTTP ${res.status} (${res.statusText})` };
119
- }
120
-
121
- const data = (await res.json()) as OpenRouterResponse;
122
- const text = data.choices?.[0]?.message?.content ?? 'No response content';
123
- return { model: modelLabel, response: text };
124
- } catch (err: unknown) {
125
- const message = err instanceof Error ? err.message : String(err);
126
- return { model: modelLabel, response: `Error: ${message}` };
127
- }
128
- }
129
-
130
- // ---------------------------------------------------------------------------
131
- // Platform helpers
132
- // ---------------------------------------------------------------------------
133
-
134
- function isCodexInstalled(): boolean {
135
- try {
136
- execFileSync('which', ['codex'], { stdio: 'ignore' });
137
- return true;
138
- } catch {
139
- return false;
140
- }
141
- }
142
-
143
- function findContextFile(): string | null {
144
- for (const name of ['GEMINI.md', 'CLAUDE.md', 'AGENTS.md']) {
145
- try {
146
- const content = readFileSync(join(process.cwd(), name), 'utf8');
147
- if (content.trim()) return content;
148
- } catch {}
149
- }
150
- return null;
151
- }
152
-
153
- function buildDeepContext(prompt: string): string {
154
- const TOKEN_CAP = 16000;
155
- const CHAR_CAP = TOKEN_CAP * 4; // ~4 chars per token
156
-
157
- const parts: string[] = [`## Prompt\n${prompt}`];
158
- let totalLen = parts[0].length;
159
-
160
- // Context file
161
- const ctxFile = findContextFile();
162
- if (ctxFile && totalLen + ctxFile.length < CHAR_CAP) {
163
- parts.push(`## Context File\n${ctxFile}`);
164
- totalLen += ctxFile.length;
165
- }
166
-
167
- // Git diff (recent changes, capped at 100KB)
168
- try {
169
- const diff = execFileSync('git', ['diff', 'HEAD~3'], {
170
- encoding: 'utf8',
171
- maxBuffer: 100 * 1024,
172
- timeout: 10_000,
173
- });
174
- if (diff && totalLen + diff.length < CHAR_CAP) {
175
- parts.push(`## Recent Git Diff\n${diff}`);
176
- totalLen += diff.length;
177
- } else if (diff) {
178
- const truncated = diff.slice(0, CHAR_CAP - totalLen - 100);
179
- parts.push(`## Recent Git Diff (truncated)\n${truncated}`);
180
- totalLen += truncated.length;
181
- }
182
- } catch {}
183
-
184
- // Directory listing (git tracked files only - respects .gitignore)
185
- try {
186
- const files = execFileSync('git', ['ls-files'], {
187
- encoding: 'utf8',
188
- timeout: 5_000,
189
- });
190
- // Filter out sensitive files
191
- const SENSITIVE = ['.env', '.pem', '.key', 'secret', 'credential', 'token'];
192
- const filtered = files.split('\n').filter((f: string) =>
193
- f && !SENSITIVE.some(s => f.toLowerCase().includes(s))
194
- ).join('\n');
195
- if (filtered && totalLen + filtered.length < CHAR_CAP) {
196
- parts.push(`## Project Files\n${filtered}`);
197
- }
198
- } catch {}
199
-
200
- return parts.join('\n\n');
201
- }
202
-
203
- // ---------------------------------------------------------------------------
204
- // MCP Server
205
- // ---------------------------------------------------------------------------
206
-
207
- const server = new McpServer({ name: 'smoothie', version: '1.0.0' });
208
-
209
- server.tool(
210
- 'smoothie_blend',
211
- {
212
- prompt: z.string().describe('The prompt to send to all models'),
213
- deep: z.boolean().optional().describe('Full context mode with project files and git diff'),
214
- },
215
- async ({ prompt, deep }) => {
216
- // Read config on every request so edits take effect immediately
217
- let config: Config;
218
- try {
219
- config = JSON.parse(
220
- readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'),
221
- ) as Config;
222
- } catch {
223
- config = { openrouter_models: [] };
224
- }
225
-
226
- const finalPrompt = deep ? buildDeepContext(prompt) : prompt;
227
-
228
- // Build model array based on platform
229
- const platform = process.env.SMOOTHIE_PLATFORM || 'claude';
230
- const models: ModelEntry[] = [];
231
-
232
- // Add platform-specific models
233
- if (platform !== 'codex' && isCodexInstalled()) {
234
- models.push({ fn: () => queryCodex(finalPrompt), label: 'Codex' });
235
- }
236
- if (platform === 'codex' || platform === 'gemini') {
237
- // Add Claude via OpenRouter as a reviewer (not the judge)
238
- models.push({
239
- fn: () => queryOpenRouter(finalPrompt, 'anthropic/claude-sonnet-4', 'Claude Sonnet'),
240
- label: 'Claude Sonnet',
241
- });
242
- }
243
-
244
- // Add OpenRouter models from config
245
- for (const m of config.openrouter_models) {
246
- models.push({
247
- fn: () => queryOpenRouter(finalPrompt, m.id, m.label),
248
- label: m.label,
249
- });
250
- }
251
-
252
- // Print initial progress
253
- process.stderr.write('\n\u{1F9C3} Smoothie blending...\n\n');
254
- for (const { label } of models) {
255
- process.stderr.write(` \u23F3 ${label.padEnd(26)} waiting...\n`);
256
- }
257
- process.stderr.write('\n');
258
-
259
- // Run all in parallel with progress tracking
260
- const startTimes: Record<string, number> = {};
261
- const promises = models.map(({ fn, label }) => {
262
- startTimes[label] = Date.now();
263
- return fn()
264
- .then((result: ModelResult) => {
265
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
266
- process.stderr.write(
267
- ` \u2713 ${label.padEnd(26)} done (${elapsed}s)\n`,
268
- );
269
- return result;
270
- })
271
- .catch((err: unknown) => {
272
- const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
273
- const message = err instanceof Error ? err.message : String(err);
274
- process.stderr.write(
275
- ` \u2717 ${label.padEnd(26)} failed (${elapsed}s)\n`,
276
- );
277
- return { model: label, response: `Error: ${message}` } as ModelResult;
278
- });
279
- });
280
-
281
- const results: ModelResult[] = await Promise.all(promises);
282
- const judgeNames: Record<string, string> = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini' };
283
- const judgeName = judgeNames[platform] || 'the judge';
284
- process.stderr.write(`\n \u25C6 All done. Handing to ${judgeName}...\n\n`);
285
-
286
- return {
287
- content: [{ type: 'text' as const, text: JSON.stringify({ results }, null, 2) }],
288
- };
289
- },
290
- );
291
-
292
- server.tool(
293
- 'smoothie_estimate',
294
- {
295
- prompt: z.string().describe('The prompt to estimate costs for'),
296
- deep: z.boolean().optional().describe('Estimate for deep mode'),
297
- },
298
- async ({ prompt, deep }) => {
299
- let config: Config;
300
- try {
301
- config = JSON.parse(readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8')) as Config;
302
- } catch {
303
- config = { openrouter_models: [] };
304
- }
305
-
306
- const contextPayload = deep ? buildDeepContext(prompt) : prompt;
307
- const tokenCount = Math.ceil(contextPayload.length / 4);
308
-
309
- // Fetch pricing from OpenRouter
310
- let pricingMap: Record<string, number> = {};
311
- try {
312
- const res = await fetch('https://openrouter.ai/api/v1/models', {
313
- headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` },
314
- });
315
- const data = (await res.json()) as { data?: Array<{ id: string; pricing?: { prompt?: string } }> };
316
- if (data.data) {
317
- for (const m of data.data) {
318
- pricingMap[m.id] = parseFloat(m.pricing?.prompt || '0');
319
- }
320
- }
321
- } catch {
322
- // Pricing unavailable — continue with zeros
323
- }
324
-
325
- const platform = process.env.SMOOTHIE_PLATFORM || 'claude';
326
- const rows: Array<{ label: string; tokens: number; cost: number; note?: string }> = [];
327
-
328
- if (platform === 'claude') {
329
- if (isCodexInstalled()) {
330
- rows.push({ label: 'Codex', tokens: tokenCount, cost: 0, note: 'free (subscription)' });
331
- }
332
- }
333
- if (platform === 'codex' || platform === 'gemini') {
334
- const price = pricingMap['anthropic/claude-sonnet-4'] || 0;
335
- rows.push({ label: 'Claude Sonnet', tokens: tokenCount, cost: tokenCount * price });
336
- }
337
- if (platform === 'gemini' && isCodexInstalled()) {
338
- rows.push({ label: 'Codex', tokens: tokenCount, cost: 0, note: 'free (subscription)' });
339
- }
340
-
341
- for (const model of config.openrouter_models) {
342
- const price = pricingMap[model.id] || 0;
343
- rows.push({
344
- label: model.label,
345
- tokens: tokenCount,
346
- cost: tokenCount * price,
347
- note: price === 0 && Object.keys(pricingMap).length === 0 ? 'pricing unavailable' : undefined,
348
- });
349
- }
350
-
351
- const totalCost = rows.reduce((sum, r) => sum + (r.cost || 0), 0);
352
-
353
- return {
354
- content: [{
355
- type: 'text' as const,
356
- text: JSON.stringify({ rows, totalCost, tokenCount, note: 'Token estimates are approximate (~4 chars/token)' }, null, 2),
357
- }],
358
- };
359
- },
360
- );
361
-
362
- // ---------------------------------------------------------------------------
363
- // Start
364
- // ---------------------------------------------------------------------------
365
-
366
- const transport = new StdioServerTransport();
367
- await server.connect(transport);