vibehacker 4.1.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/src/approve.js ADDED
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const blessed = require('blessed');
4
+
5
+ // ── Tool descriptions for the approval dialog ─────────────────────────────────
6
+ const TOOL_VERB = {
7
+ write_file: 'Write file',
8
+ edit_file: 'Edit file',
9
+ execute_command: 'Run command',
10
+ delete_file: 'Delete file',
11
+ create_directory: 'Create directory',
12
+ read_file: 'Read file',
13
+ list_files: 'List files',
14
+ search_files: 'Search files',
15
+ grep: 'Search contents',
16
+ glob: 'Find files',
17
+ };
18
+
19
+ // Tools that need approval (risky)
20
+ const NEEDS_APPROVAL = new Set([
21
+ 'write_file', 'edit_file', 'execute_command', 'delete_file',
22
+ ]);
23
+
24
+ // ── Format tool args into a human-readable description ────────────────────────
25
+ function describeToolCall(tc) {
26
+ const { name, args } = tc;
27
+ if (name === 'write_file') return args.path || '(unknown path)';
28
+ if (name === 'execute_command') return args.command || '(unknown command)';
29
+ if (name === 'delete_file') return args.path || '(unknown path)';
30
+ if (name === 'create_directory') return args.path || '(unknown path)';
31
+ if (name === 'read_file') return args.path || '(unknown path)';
32
+ if (name === 'list_files') return args.path || '.';
33
+ if (name === 'search_files') return `"${args.pattern || ''}" in ${args.path || '.'}`;
34
+ return JSON.stringify(args).substring(0, 80);
35
+ }
36
+
37
+ // Get the path/command for "always allow" matching
38
+ function getAllowKey(tc) {
39
+ const { name, args } = tc;
40
+ if (name === 'execute_command') {
41
+ // Allow by command prefix (first word)
42
+ const cmd = (args.command || '').trim().split(/\s+/)[0];
43
+ return `cmd:${cmd}`;
44
+ }
45
+ // Allow by directory for file ops
46
+ const p = args.path || '';
47
+ const parts = p.replace(/\\/g, '/').split('/');
48
+ return 'path:' + parts.slice(0, -1).join('/'); // parent dir
49
+ }
50
+
51
+ // ── Main dialog ───────────────────────────────────────────────────────────────
52
+ /**
53
+ * Show tool approval dialog.
54
+ * Returns Promise<'yes' | 'always' | 'no'>
55
+ *
56
+ * @param {object} screen - blessed screen
57
+ * @param {object} tc - tool call { name, args }
58
+ * @param {string} context - optional one-line AI description of what it's doing
59
+ */
60
+ function showApproval(screen, tc, context) {
61
+ return new Promise((resolve) => {
62
+ const verb = TOOL_VERB[tc.name] || tc.name;
63
+ const detail = describeToolCall(tc);
64
+ const allowKey = getAllowKey(tc);
65
+ const isCmd = tc.name === 'execute_command';
66
+
67
+ // "Yes, and always allow…" label
68
+ const alwaysLabel = isCmd
69
+ ? `Yes, and always allow {bold}${(tc.args.command || '').split(/\s+/)[0]}{/bold} commands`
70
+ : `Yes, and always allow writes to {bold}${(detail.replace(/\\/g,'/').split('/').slice(0,-1).join('/') || detail)}{/bold}`;
71
+
72
+ const items = [
73
+ { key: '1', label: 'Yes', value: 'yes', color: '#00ff88' },
74
+ { key: '2', label: alwaysLabel, value: 'always', color: '#88ff44' },
75
+ { key: '3', label: 'No', value: 'no', color: '#ff4444' },
76
+ ];
77
+
78
+ const W = Math.min(Math.max(40, screen.width - 6), 70);
79
+
80
+ // Fixed chrome: border(2) + blank(1) + header(1) + detail(1) + blank(1)
81
+ // + "Do you want to proceed?"(1) + blank(1) + buttons(items.length)
82
+ // + blank(1) + hint(1) = 10 + items.length
83
+ // Everything else (context line + preview) must fit in remaining space.
84
+ const maxH = Math.max(10, screen.height - 4);
85
+ const fixedH = 10 + items.length;
86
+ const budget = Math.max(0, maxH - fixedH);
87
+
88
+ // Context eats 1 line if present
89
+ const ctxLine = context ? `{#444444-fg}${esc(context)}{/#444444-fg}` : '';
90
+ const ctxCost = ctxLine ? 1 : 0;
91
+
92
+ // Preview: clamp to remaining budget (separator + lines)
93
+ let preview = '';
94
+ if (tc.name === 'write_file' && tc.args.content) {
95
+ preview = tc.args.content;
96
+ } else if (tc.name === 'execute_command') {
97
+ preview = tc.args.command || '';
98
+ }
99
+ const previewBudget = Math.max(0, budget - ctxCost - (preview ? 1 : 0)); // -1 for separator
100
+ if (preview) {
101
+ const allLines = preview.split('\n');
102
+ if (allLines.length > previewBudget) {
103
+ preview = allLines.slice(0, Math.max(1, previewBudget - 1)).join('\n') + '\n…';
104
+ }
105
+ // Also hard-cap to 6 lines regardless, to keep dialog compact
106
+ const cappedLines = preview.split('\n');
107
+ if (cappedLines.length > 6) {
108
+ preview = cappedLines.slice(0, 5).join('\n') + '\n…';
109
+ }
110
+ }
111
+ const previewCost = preview ? preview.split('\n').length + 1 : 0; // +1 separator
112
+
113
+ const H = Math.min(maxH, fixedH + ctxCost + previewCost);
114
+
115
+ const box = blessed.box({
116
+ parent: screen,
117
+ top: 'center',
118
+ left: 'center',
119
+ width: W,
120
+ height: Math.min(H, screen.height - 4),
121
+ tags: true,
122
+ border: { type: 'line' },
123
+ shadow: false,
124
+ style: {
125
+ fg: '#00ff88', bg: '#000a00',
126
+ border: { fg: '#00aa44' },
127
+ },
128
+ keys: true,
129
+ mouse: true,
130
+ });
131
+
132
+ function buildContent(selectedIdx) {
133
+ const lines = [];
134
+ lines.push('');
135
+
136
+ // Header
137
+ lines.push(` {bold}{#00ff88-fg}${esc(verb)}{/}{/bold}`);
138
+
139
+ // Detail (path / command)
140
+ lines.push(` {#88ff88-fg}${esc(detail)}{/#88ff88-fg}`);
141
+
142
+ // Context
143
+ if (ctxLine) lines.push(` ${ctxLine}`);
144
+
145
+ // Preview block
146
+ if (preview) {
147
+ lines.push(` {#333333-fg}${'─'.repeat(W - 4)}{/#333333-fg}`);
148
+ preview.split('\n').forEach(l => {
149
+ lines.push(` {#555555-fg}${esc(l.substring(0, W - 4))}{/#555555-fg}`);
150
+ });
151
+ }
152
+
153
+ lines.push('');
154
+ lines.push(` {#888888-fg}Do you want to proceed?{/#888888-fg}`);
155
+ lines.push('');
156
+
157
+ // Menu items
158
+ items.forEach((item, i) => {
159
+ const cursor = i === selectedIdx ? '{yellow-fg}❯{/yellow-fg}' : ' ';
160
+ const numBadge = `{#444444-fg}${item.key}.{/#444444-fg}`;
161
+ const labelStr = i === selectedIdx
162
+ ? `{bold}{${item.color}-fg}${item.label}{/}{/bold}`
163
+ : `{#888888-fg}${item.label}{/#888888-fg}`;
164
+ lines.push(` ${cursor} ${numBadge} ${labelStr}`);
165
+ });
166
+
167
+ lines.push('');
168
+ lines.push(` {#333333-fg}Esc cancel · ↑↓ navigate · Enter confirm{/#333333-fg}`);
169
+
170
+ return lines.join('\n');
171
+ }
172
+
173
+ let selectedIdx = 0;
174
+ box.setContent(buildContent(selectedIdx));
175
+ box.focus();
176
+ screen.render();
177
+
178
+ function done(value) {
179
+ try { box.destroy(); } catch (_) {}
180
+ screen.render();
181
+ resolve(value);
182
+ }
183
+
184
+ // Keyboard navigation
185
+ box.on('keypress', (ch, key) => {
186
+ if (!key) return;
187
+ const name = key.name;
188
+
189
+ if (name === 'escape') { done('no'); return; }
190
+ if (name === 'up') { selectedIdx = Math.max(0, selectedIdx - 1); box.setContent(buildContent(selectedIdx)); screen.render(); return; }
191
+ if (name === 'down') { selectedIdx = Math.min(items.length - 1, selectedIdx + 1); box.setContent(buildContent(selectedIdx)); screen.render(); return; }
192
+ if (name === 'enter' || name === 'return') { done(items[selectedIdx].value); return; }
193
+
194
+ // Number shortcuts
195
+ if (ch === '1') { done('yes'); return; }
196
+ if (ch === '2') { done('always'); return; }
197
+ if (ch === '3') { done('no'); return; }
198
+ if (ch === 'y' || ch === 'Y') { done('yes'); return; }
199
+ if (ch === 'n' || ch === 'N') { done('no'); return; }
200
+ });
201
+
202
+ // Mouse click on items
203
+ items.forEach((item, i) => {
204
+ box.on('click', () => {
205
+ done(items[selectedIdx].value);
206
+ });
207
+ });
208
+
209
+ screen.render();
210
+ });
211
+ }
212
+
213
+ function esc(str) {
214
+ return String(str).replace(/\{/g, '\\{').replace(/\}/g, '\\}');
215
+ }
216
+
217
+ module.exports = { showApproval, NEEDS_APPROVAL, getAllowKey };
package/src/auth.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // ── Vibe Hacker Auth — simple API key entry ──────────────────────────────
4
+ // No browser popup. User gets key from vibsecurity.com, pastes it.
5
+
6
+ const LOGIN_URL = process.env.VH_LOGIN_URL || 'https://vibsecurity.com/login';
7
+
8
+ function openBrowser(url) {
9
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
10
+ return Promise.resolve(false);
11
+ }
12
+ const { spawn } = require('child_process');
13
+ const platform = process.platform;
14
+ return new Promise((resolve) => {
15
+ let proc;
16
+ try {
17
+ if (platform === 'win32') {
18
+ proc = spawn('cmd.exe', ['/c', 'start', '""', url], {
19
+ detached: true, stdio: 'ignore', windowsVerbatimArguments: false,
20
+ });
21
+ } else if (platform === 'darwin') {
22
+ proc = spawn('open', [url], { detached: true, stdio: 'ignore' });
23
+ } else {
24
+ proc = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
25
+ proc.on('error', () => {
26
+ try {
27
+ const fb = spawn('wslview', [url], { detached: true, stdio: 'ignore' });
28
+ fb.on('error', () => resolve(false));
29
+ fb.unref();
30
+ resolve(true);
31
+ } catch (_) { resolve(false); }
32
+ });
33
+ }
34
+ if (proc) {
35
+ proc.on('error', () => resolve(false));
36
+ proc.unref();
37
+ setTimeout(() => resolve(true), 100);
38
+ }
39
+ } catch (_) {
40
+ resolve(false);
41
+ }
42
+ });
43
+ }
44
+
45
+ module.exports = { openBrowser, LOGIN_URL };
package/src/config.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.vibehacker');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+
10
+ // Migrate from old ~/.hackerai config if it exists
11
+ try {
12
+ const oldDir = path.join(os.homedir(), '.hackerai');
13
+ const oldFile = path.join(oldDir, 'config.json');
14
+ if (fs.existsSync(oldFile) && !fs.existsSync(CONFIG_FILE)) {
15
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
16
+ fs.copyFileSync(oldFile, CONFIG_FILE);
17
+ }
18
+ } catch (_) {}
19
+
20
+ // Load once at startup — no repeated fs reads
21
+ let _saved;
22
+ try {
23
+ _saved = fs.existsSync(CONFIG_FILE) ? JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) : {};
24
+ } catch (_) { _saved = {}; }
25
+
26
+ function saveConfig(data) {
27
+ try {
28
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
29
+ // Merge with in-memory saved state (not disk — avoid re-read)
30
+ Object.assign(_saved, data);
31
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(_saved, null, 2));
32
+ } catch (_) {}
33
+ }
34
+
35
+ const config = {
36
+ apiKey: _saved.apiKey || '',
37
+ baseURL: _saved.baseURL || 'https://openrouter.ai/api/v1',
38
+ appName: 'Vibe Hacker',
39
+ version: '4.1.0',
40
+ noLogging: true,
41
+ maxTokens: 8192,
42
+ temperature: 0.6, // slightly lower for more reliable tool use
43
+ maxToolIterations: 30, // generous for complex tasks
44
+ httpReferer: 'https://vibsecurity.com',
45
+ xTitle: 'Vibe Hacker',
46
+
47
+ providers: _saved.providers || [],
48
+
49
+ hasKey() {
50
+ return !!(this.apiKey && this.apiKey.length > 10);
51
+ },
52
+
53
+ setApiKey(key) {
54
+ this.apiKey = key;
55
+ saveConfig({ apiKey: key });
56
+ },
57
+ };
58
+
59
+ module.exports = config;
package/src/models.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ // Built-in free models. Run /refresh to update from API.
4
+ // Ordered by speed — faster models first for better default experience.
5
+ const FREE_MODELS = [
6
+ {
7
+ id: 'meta-llama/llama-3.3-70b-instruct:free',
8
+ name: 'llama-3.3-70b',
9
+ provider: 'Meta',
10
+ description: 'Llama 3.3 70B — fast & capable',
11
+ contextWindow: 131072,
12
+ },
13
+ {
14
+ id: 'qwen/qwen3-coder:free',
15
+ name: 'qwen3-coder',
16
+ provider: 'Qwen',
17
+ description: 'Dedicated coding model — 262k context',
18
+ contextWindow: 262000,
19
+ },
20
+ {
21
+ id: 'qwen/qwen3.6-plus:free',
22
+ name: 'qwen3.6-plus',
23
+ provider: 'Qwen',
24
+ description: 'Qwen 3.6 Plus — 1M context, very capable',
25
+ contextWindow: 1000000,
26
+ },
27
+ {
28
+ id: 'qwen/qwen3-next-80b-a3b-instruct:free',
29
+ name: 'qwen3-next-80b',
30
+ provider: 'Qwen',
31
+ description: 'Qwen3 Next 80B instruction model',
32
+ contextWindow: 262144,
33
+ },
34
+ {
35
+ id: 'nvidia/nemotron-3-super-120b-a12b:free',
36
+ name: 'nemotron-120b',
37
+ provider: 'NVIDIA',
38
+ description: 'Nemotron 3 Super 120B — 262k context',
39
+ contextWindow: 262144,
40
+ },
41
+ {
42
+ id: 'nvidia/nemotron-3-nano-30b-a3b:free',
43
+ name: 'nemotron-30b',
44
+ provider: 'NVIDIA',
45
+ description: 'Nemotron 3 Nano 30B — 256k context',
46
+ contextWindow: 256000,
47
+ },
48
+ {
49
+ id: 'nvidia/nemotron-nano-12b-v2-vl:free',
50
+ name: 'nemotron-12b-vl',
51
+ provider: 'NVIDIA',
52
+ description: 'Nemotron Nano 12B vision-language',
53
+ contextWindow: 128000,
54
+ },
55
+ {
56
+ id: 'nvidia/nemotron-nano-9b-v2:free',
57
+ name: 'nemotron-9b',
58
+ provider: 'NVIDIA',
59
+ description: 'Nemotron Nano 9B — 128k context',
60
+ contextWindow: 128000,
61
+ },
62
+ {
63
+ id: 'meta-llama/llama-3.2-3b-instruct:free',
64
+ name: 'llama-3.2-3b',
65
+ provider: 'Meta',
66
+ description: 'Llama 3.2 3B — fast & lightweight',
67
+ contextWindow: 131072,
68
+ },
69
+ {
70
+ id: 'nousresearch/hermes-3-llama-3.1-405b:free',
71
+ name: 'hermes-3-405b',
72
+ provider: 'Nous',
73
+ description: 'Hermes 3 on Llama 405B — very capable',
74
+ contextWindow: 131072,
75
+ },
76
+ {
77
+ id: 'google/gemma-3-27b-it:free',
78
+ name: 'gemma-3-27b',
79
+ provider: 'Google',
80
+ description: 'Gemma 3 27B instruction-tuned',
81
+ contextWindow: 131072,
82
+ },
83
+ {
84
+ id: 'google/gemma-3-12b-it:free',
85
+ name: 'gemma-3-12b',
86
+ provider: 'Google',
87
+ description: 'Gemma 3 12B instruction-tuned',
88
+ contextWindow: 32768,
89
+ },
90
+ {
91
+ id: 'google/gemma-3-4b-it:free',
92
+ name: 'gemma-3-4b',
93
+ provider: 'Google',
94
+ description: 'Gemma 3 4B — fast & compact',
95
+ contextWindow: 32768,
96
+ },
97
+ {
98
+ id: 'arcee-ai/trinity-large-preview:free',
99
+ name: 'trinity-large',
100
+ provider: 'Arcee',
101
+ description: 'Trinity Large Preview — 131k context',
102
+ contextWindow: 131000,
103
+ },
104
+ {
105
+ id: 'arcee-ai/trinity-mini:free',
106
+ name: 'trinity-mini',
107
+ provider: 'Arcee',
108
+ description: 'Trinity Mini — fast, 131k context',
109
+ contextWindow: 131072,
110
+ },
111
+ {
112
+ id: 'minimax/minimax-m2.5:free',
113
+ name: 'minimax-m2.5',
114
+ provider: 'MiniMax',
115
+ description: 'MiniMax M2.5 — 196k context',
116
+ contextWindow: 196608,
117
+ },
118
+ {
119
+ id: 'stepfun/step-3.5-flash:free',
120
+ name: 'step-3.5-flash',
121
+ provider: 'StepFun',
122
+ description: 'Step 3.5 Flash — fast, 256k context',
123
+ contextWindow: 256000,
124
+ },
125
+ {
126
+ id: 'z-ai/glm-4.5-air:free',
127
+ name: 'glm-4.5-air',
128
+ provider: 'Z-AI',
129
+ description: 'GLM 4.5 Air — 131k context',
130
+ contextWindow: 131072,
131
+ },
132
+ {
133
+ id: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
134
+ name: 'dolphin-24b',
135
+ provider: 'CogComp',
136
+ description: 'Dolphin Mistral 24B Venice Edition',
137
+ contextWindow: 32768,
138
+ },
139
+ {
140
+ id: 'liquid/lfm-2.5-1.2b-thinking:free',
141
+ name: 'lfm-thinking-1.2b',
142
+ provider: 'Liquid',
143
+ description: 'LFM 2.5 1.2B with thinking mode',
144
+ contextWindow: 32768,
145
+ },
146
+ ];
147
+
148
+ class ModelManager {
149
+ constructor() {
150
+ this.models = FREE_MODELS;
151
+ this.currentIndex = 0;
152
+ }
153
+
154
+ current() {
155
+ return this.models[this.currentIndex];
156
+ }
157
+
158
+ next() {
159
+ this.currentIndex = (this.currentIndex + 1) % this.models.length;
160
+ return this.current();
161
+ }
162
+
163
+ prev() {
164
+ this.currentIndex = (this.currentIndex - 1 + this.models.length) % this.models.length;
165
+ return this.current();
166
+ }
167
+
168
+ selectById(id) {
169
+ const idx = this.models.findIndex(m => m.id === id);
170
+ if (idx !== -1) {
171
+ this.currentIndex = idx;
172
+ return this.current();
173
+ }
174
+ return null;
175
+ }
176
+
177
+ list() {
178
+ return this.models;
179
+ }
180
+
181
+ // Refresh model list from API — replaces list with all live free models
182
+ async refresh(apiKey, baseURL) {
183
+ try {
184
+ const axios = require('axios');
185
+ const r = await axios.get(`${baseURL}/models`, {
186
+ headers: { 'Authorization': `Bearer ${apiKey}` },
187
+ timeout: 15000,
188
+ });
189
+ const allModels = r.data?.data || [];
190
+ // Get all free models from API
191
+ const live = allModels.filter(m =>
192
+ m.id.endsWith(':free') && m.pricing?.prompt === '0'
193
+ );
194
+ if (live.length > 0) {
195
+ // Sort by context window (largest first), then alphabetically
196
+ live.sort((a, b) => (b.context_length || 0) - (a.context_length || 0) || a.id.localeCompare(b.id));
197
+
198
+ const currentId = this.models[this.currentIndex]?.id;
199
+ this.models = live.map(m => ({
200
+ id: m.id,
201
+ name: m.id.split('/')[1].replace(':free', ''),
202
+ provider: m.id.split('/')[0],
203
+ description: m.name || m.id,
204
+ contextWindow: m.context_length || 32768,
205
+ free: true,
206
+ }));
207
+
208
+ // Try to keep current selection
209
+ const newIdx = this.models.findIndex(m => m.id === currentId);
210
+ this.currentIndex = newIdx >= 0 ? newIdx : 0;
211
+ return this.models.length;
212
+ }
213
+ } catch (_) {}
214
+ return 0;
215
+ }
216
+ }
217
+
218
+ module.exports = { ModelManager, FREE_MODELS };