vibehacker 4.1.0 → 4.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/src/approve.js CHANGED
@@ -25,6 +25,7 @@ const NEEDS_APPROVAL = new Set([
25
25
  function describeToolCall(tc) {
26
26
  const { name, args } = tc;
27
27
  if (name === 'write_file') return args.path || '(unknown path)';
28
+ if (name === 'edit_file') return args.path || '(unknown path)';
28
29
  if (name === 'execute_command') return args.command || '(unknown command)';
29
30
  if (name === 'delete_file') return args.path || '(unknown path)';
30
31
  if (name === 'create_directory') return args.path || '(unknown path)';
@@ -77,40 +78,34 @@ function showApproval(screen, tc, context) {
77
78
 
78
79
  const W = Math.min(Math.max(40, screen.width - 6), 70);
79
80
 
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);
81
+ // Fixed chrome lines:
82
+ // border-top(1) + blank(1) + header(1) + detail(1) + blank(1)
83
+ // + "Do you want to proceed?"(1) + blank(1) + buttons(3)
84
+ // + blank(1) + hint(1) + border-bottom(1) = 13
85
+ const CHROME = 13;
86
+ const maxH = Math.max(CHROME, screen.height - 2);
87
87
 
88
88
  // Context eats 1 line if present
89
89
  const ctxLine = context ? `{#444444-fg}${esc(context)}{/#444444-fg}` : '';
90
90
  const ctxCost = ctxLine ? 1 : 0;
91
91
 
92
- // Preview: clamp to remaining budget (separator + lines)
92
+ // Preview: hard-cap to 4 lines max buttons MUST always be visible
93
93
  let preview = '';
94
94
  if (tc.name === 'write_file' && tc.args.content) {
95
95
  preview = tc.args.content;
96
96
  } else if (tc.name === 'execute_command') {
97
97
  preview = tc.args.command || '';
98
98
  }
99
- const previewBudget = Math.max(0, budget - ctxCost - (preview ? 1 : 0)); // -1 for separator
100
99
  if (preview) {
101
100
  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…';
101
+ const maxPreviewLines = Math.min(4, Math.max(0, maxH - CHROME - ctxCost - 1));
102
+ if (allLines.length > maxPreviewLines) {
103
+ preview = allLines.slice(0, Math.max(1, maxPreviewLines - 1)).join('\n') + '\n…';
109
104
  }
110
105
  }
111
106
  const previewCost = preview ? preview.split('\n').length + 1 : 0; // +1 separator
112
107
 
113
- const H = Math.min(maxH, fixedH + ctxCost + previewCost);
108
+ const H = Math.min(maxH, CHROME + ctxCost + previewCost);
114
109
 
115
110
  const box = blessed.box({
116
111
  parent: screen,
package/src/providers.js CHANGED
@@ -10,7 +10,7 @@ const PROVIDERS = {
10
10
  name: 'Vibe Hacker',
11
11
  shortName: 'Vibe Hacker',
12
12
  baseURL: VH_GATEWAY_URL,
13
- freeNote: '100 req/day free',
13
+ freeNote: '50 req/day free',
14
14
  tier: 'free',
15
15
  detectKey: k => k.startsWith('vh_'),
16
16
  getKey: () => 'Sign up at https://vibsecurity.com',
@@ -29,7 +29,7 @@ const PROVIDERS = {
29
29
  name: 'Vibe Hacker',
30
30
  shortName: 'Vibe Hacker',
31
31
  baseURL: 'https://openrouter.ai/api/v1',
32
- freeNote: '100 req/day free',
32
+ freeNote: '50 req/day free',
33
33
  tier: 'free',
34
34
  detectKey: k => k.startsWith('sk-or-v1-'),
35
35
  getKey: () => 'vibsecurity.com/login',
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { streamChat } = require('./api');
6
+ const { parseToolCalls, executeTool, TOOL_DOCS } = require('./tools');
7
+ const config = require('./config');
8
+
9
+ // ── Modes ────────────────────────────────────────────────────────────────────
10
+
11
+ const MODES = [
12
+ { id: 'chat', name: 'Chat', description: 'Security Q&A and threat intel' },
13
+ { id: 'hunt', name: 'Hunt', description: 'Autonomous coding, security ops & tool use' },
14
+ ];
15
+
16
+ // ── Project Memory ───────────────────────────────────────────────────────────
17
+ // Cached — only reads filesystem once per cwd, refreshes when cwd changes.
18
+
19
+ let _memCache = { cwd: null, content: null };
20
+
21
+ function loadProjectMemory(cwd) {
22
+ if (_memCache.cwd === cwd) return _memCache.content;
23
+
24
+ const candidates = [
25
+ path.join(cwd, 'VIBEHACKER.md'),
26
+ path.join(cwd, '.vibehacker', 'context.md'),
27
+ path.join(cwd, '.vibehacker', 'instructions.md'),
28
+ ];
29
+ let result = null;
30
+ for (const f of candidates) {
31
+ try {
32
+ const c = fs.readFileSync(f, 'utf8');
33
+ if (c.trim()) { result = { file: path.relative(cwd, f), content: c.trim() }; break; }
34
+ } catch (_) {}
35
+ }
36
+ _memCache = { cwd, content: result };
37
+ return result;
38
+ }
39
+
40
+ // ── System Prompt — Cached, Only Rebuilt When cwd/mode Changes ───────────────
41
+
42
+ let _promptCache = { mode: null, cwd: null, prompt: null };
43
+
44
+ function buildSystemPrompt(mode, cwd) {
45
+ if (_promptCache.mode === mode && _promptCache.cwd === cwd && _promptCache.prompt) {
46
+ return _promptCache.prompt;
47
+ }
48
+
49
+ const os = process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux';
50
+ const shell = process.platform === 'win32' ? 'powershell' : 'bash';
51
+ const date = new Date().toISOString().split('T')[0];
52
+
53
+ const projectMem = loadProjectMemory(cwd);
54
+ const projectSection = projectMem
55
+ ? `\n\n# Project Instructions (${projectMem.file})\n${projectMem.content}\n`
56
+ : '';
57
+
58
+ let prompt;
59
+
60
+ if (mode === 'hunt') {
61
+ prompt = `You are Vibe Hacker v${config.version} — an expert autonomous AI agent.
62
+
63
+ # Environment
64
+ - CWD: ${cwd}
65
+ - OS: ${os} | Shell: ${shell} | Date: ${date}
66
+ ${projectSection}
67
+ # HUNT MODE — Autonomous Agent with Tool Access
68
+
69
+ You have filesystem + shell access via XML tool blocks. DO the work — don't describe it.
70
+
71
+ ${TOOL_DOCS}
72
+
73
+ # Rules (MANDATORY)
74
+
75
+ 1. USE TOOLS. Don't explain what you'd do — DO IT with tool calls.
76
+ ✗ "You could run npm install" → ✓ <execute_command><command>npm install</command></execute_command>
77
+
78
+ 2. READ BEFORE EDIT. Always read_file before edit_file. The tool rejects edits on unread files.
79
+
80
+ 3. EDIT > WRITE. Modify existing files with edit_file (surgical replacement). Only write_file for NEW files.
81
+
82
+ 4. EXACT MATCHING. edit_file old_string must match the file exactly — whitespace, indentation, everything.
83
+ If it fails: read the file again, the content changed. Add more surrounding context if not unique.
84
+
85
+ 5. MULTIPLE TOOLS OK. Use several tools in one response when they're independent.
86
+
87
+ 6. GREP > MANUAL SEARCH. Use grep/glob to find code. Don't read every file looking for something.
88
+
89
+ 7. NON-INTERACTIVE COMMANDS ONLY. No vim, nano, interactive prompts. Use -y/--yes flags. 2 min timeout.
90
+
91
+ 8. COMPLETE CODE. Never write "// ...", "// TODO", "// rest of code". Write the full implementation.
92
+
93
+ 9. VERIFY. After changes: read back files, run tests, fix issues. Don't declare done without checking.
94
+
95
+ # Error Recovery
96
+
97
+ - edit_file "not found" → Read file again. Check whitespace. Content may have changed.
98
+ - edit_file "multiple matches" → Add more surrounding lines to old_string.
99
+ - Command failed → Read error. Check dependencies. Try different approach.
100
+ - File not found → Use glob/list_files to find correct path.
101
+
102
+ # Workflow: EXPLORE → PLAN (1-2 sentences) → EXECUTE → VERIFY
103
+
104
+ # Style: Direct. No filler. Brief explanations between tools. Summary when done.`;
105
+
106
+ } else {
107
+ prompt = `You are Vibe Hacker v${config.version} — expert AI for cybersecurity and engineering.
108
+
109
+ # Environment
110
+ - OS: ${os} | Date: ${date}
111
+ ${projectSection}
112
+ # CHAT MODE — Expert Answers
113
+
114
+ Direct, accurate, actionable. No filler. Markdown with language-tagged code blocks.
115
+ For security: include attack vectors + mitigations. For code: working examples, not pseudocode.`;
116
+ }
117
+
118
+ _promptCache = { mode, cwd, prompt };
119
+ return prompt;
120
+ }
121
+
122
+ // ── Thinking Extraction ──────────────────────────────────────────────────────
123
+
124
+ function extractThinking(text) {
125
+ let visible = text;
126
+ let thinking = '';
127
+ // Remove <think>...</think> and <thinking>...</thinking> blocks
128
+ visible = visible.replace(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/g, (_, t) => {
129
+ thinking += t.trim() + '\n';
130
+ return '';
131
+ });
132
+ return { visible: visible.replace(/\n{3,}/g, '\n\n').trim(), thinking: thinking.trim() };
133
+ }
134
+
135
+ // ── Token Estimation (fast, cached per string length) ────────────────────────
136
+
137
+ function estimateTokens(text) {
138
+ if (!text) return 0;
139
+ // Empirical: ~3.5 chars per token for mixed code/text
140
+ return Math.ceil(text.length / 3.5);
141
+ }
142
+
143
+ // ── Context Trimming — Proactive, Prioritized ────────────────────────────────
144
+
145
+ function trimHistory(messages, maxContextTokens) {
146
+ // Fast total estimate
147
+ let total = 0;
148
+ for (let i = 0; i < messages.length; i++) total += estimateTokens(messages[i].content);
149
+
150
+ const budget = Math.floor(maxContextTokens * 0.55); // 45% headroom for response + tool results
151
+ if (total <= budget) return messages;
152
+
153
+ // Phase 1: Strip thinking blocks from old assistant messages
154
+ let trimmed = messages.map((m, i) => {
155
+ if (i === 0 || i >= messages.length - 6) return m; // keep system + recent
156
+ if (m.role === 'assistant' && (m.content.includes('<think>') || m.content.includes('<thinking>'))) {
157
+ const { visible } = extractThinking(m.content);
158
+ return { ...m, content: visible };
159
+ }
160
+ return m;
161
+ });
162
+
163
+ total = 0;
164
+ for (const m of trimmed) total += estimateTokens(m.content);
165
+ if (total <= budget) return trimmed;
166
+
167
+ // Phase 2: Compress old tool results to headers only
168
+ trimmed = trimmed.map((m, i) => {
169
+ if (i === 0 || i >= trimmed.length - 6) return m;
170
+ if (m.role === 'user' && m.content.startsWith('[Tool Result:') && m.content.length > 800) {
171
+ return { ...m, content: m.content.split('\n')[0] + '\n[output trimmed]' };
172
+ }
173
+ if (m.role === 'assistant' && m.content.length > 1500 && i < trimmed.length - 8) {
174
+ return { ...m, content: m.content.substring(0, 400) + '\n[...]\n' + m.content.slice(-300) };
175
+ }
176
+ return m;
177
+ });
178
+
179
+ total = 0;
180
+ for (const m of trimmed) total += estimateTokens(m.content);
181
+ if (total <= budget) return trimmed;
182
+
183
+ // Phase 3: Drop middle messages
184
+ const keep = Math.min(8, trimmed.length - 2);
185
+ if (trimmed.length <= keep + 2) return trimmed;
186
+ const dropped = trimmed.length - keep - 1;
187
+ return [
188
+ trimmed[0],
189
+ { role: 'user', content: `[${dropped} earlier messages trimmed for context]` },
190
+ ...trimmed.slice(-keep),
191
+ ];
192
+ }
193
+
194
+ // ── Agent ────────────────────────────────────────────────────────────────────
195
+
196
+ class Agent {
197
+ constructor() {
198
+ this.history = [];
199
+ this.mode = 'chat';
200
+ this.cwd = process.cwd();
201
+ }
202
+
203
+ setMode(mode) { this.mode = mode; this.history = []; _promptCache.mode = null; }
204
+ setCwd(dir) { this.cwd = dir; _promptCache.cwd = null; _memCache.cwd = null; }
205
+ clearHistory(){ this.history = []; }
206
+
207
+ async run({ userMessage, model, signal, onToken, onDone, onError, onToolCall, onToolResult, beforeToolCall }) {
208
+ this.history.push({ role: 'user', content: userMessage });
209
+
210
+ const maxCtx = (model && model.contextWindow) || 32768;
211
+ const maxIter = config.maxToolIterations || 25;
212
+ let iterations = 0;
213
+
214
+ // ── Iterative agent loop ──────────────────────────────────────────
215
+ while (true) {
216
+ if (signal && signal.aborted) {
217
+ onError(Object.assign(new Error('aborted'), { type: 'ABORTED' }));
218
+ return;
219
+ }
220
+
221
+ iterations++;
222
+ if (iterations > maxIter) {
223
+ onDone('[Tool iteration limit reached. Use /retry to continue.]');
224
+ return;
225
+ }
226
+
227
+ // Build messages with proactive trimming (BEFORE sending)
228
+ const sysPrompt = buildSystemPrompt(this.mode, this.cwd);
229
+ let messages = [{ role: 'system', content: sysPrompt }, ...this.history];
230
+ messages = trimHistory(messages, maxCtx);
231
+
232
+ // ── Stream response ─────────────────────────────────────────────
233
+ let fullResponse = '';
234
+ let streamError = null;
235
+
236
+ await new Promise((resolve) => {
237
+ let resolved = false;
238
+ const done = () => { if (!resolved) { resolved = true; resolve(); } };
239
+ streamChat({
240
+ messages, model: model.id, signal,
241
+ maxTokens: model.maxTokens || config.maxTokens || 8192,
242
+ onToken: (token, full) => { fullResponse = full; if (onToken) onToken(token, full); },
243
+ onDone: (content) => { fullResponse = content || fullResponse; done(); },
244
+ onError: (errObj) => { streamError = errObj; done(); },
245
+ });
246
+ });
247
+
248
+ // Rate limit backoff inside tool loop — rotate-first strategy
249
+ if (streamError && streamError.type === 'RATE_LIMIT' && iterations > 1) {
250
+ // Don't retry same model. Surface error to app.js for provider rotation.
251
+ onError(Object.assign(new Error(streamError.msg || 'Rate limited'), streamError));
252
+ return;
253
+ }
254
+ if (streamError) {
255
+ onError(Object.assign(new Error(streamError.msg || 'error'), streamError));
256
+ return;
257
+ }
258
+ if (signal && signal.aborted) return;
259
+
260
+ // Store full response in history (including thinking for continuity)
261
+ this.history.push({ role: 'assistant', content: fullResponse });
262
+
263
+ // Chat mode — done after single response
264
+ if (this.mode !== 'hunt') {
265
+ const { visible } = extractThinking(fullResponse);
266
+ onDone(visible || fullResponse);
267
+ return;
268
+ }
269
+
270
+ // Hunt mode — parse tool calls
271
+ const toolCalls = parseToolCalls(fullResponse);
272
+ if (toolCalls.length === 0) {
273
+ const { visible } = extractThinking(fullResponse);
274
+ onDone(visible || fullResponse);
275
+ return;
276
+ }
277
+
278
+ // Execute all tools
279
+ for (const tc of toolCalls) {
280
+ if (signal && signal.aborted) return;
281
+
282
+ if (onToolCall) onToolCall(tc);
283
+
284
+ if (beforeToolCall) {
285
+ const decision = await beforeToolCall(tc);
286
+ if (decision === 'no') {
287
+ this.history.push({ role: 'user', content: `[Tool Denied: ${tc.name}] User rejected. Try a different approach.` });
288
+ continue;
289
+ }
290
+ }
291
+
292
+ let result;
293
+ const toolStart = Date.now();
294
+ try {
295
+ result = await executeTool(tc, this.cwd);
296
+ } catch (err) {
297
+ result = `[Error: ${tc.name}] ${err.message}`;
298
+ }
299
+
300
+ if (onToolResult) onToolResult(tc, result);
301
+ this.history.push({ role: 'user', content: `[Tool Result: ${tc.name}]\n${result}` });
302
+ }
303
+
304
+ // Adaptive throttle based on tool types — minimal delay
305
+ const hasWrite = toolCalls.some(tc => ['write_file', 'edit_file', 'execute_command', 'delete_file'].includes(tc.name));
306
+ await new Promise(r => setTimeout(r, hasWrite ? 300 : 100));
307
+ }
308
+ }
309
+ }
310
+
311
+ module.exports = { Agent, MODES };