@hamp10/agentforge 0.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.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * HampAgentCLI — drop-in replacement for OpenClawCLI.
3
+ * Uses claude CLI subprocess with stream-json — works with Claude Max plan, no API key needed.
4
+ * Emits identical events (agent_output, tool_activity, agent_image, agent_alive, agent_completed)
5
+ * so worker.js can route tasks to either runner transparently.
6
+ */
7
+
8
+ import { EventEmitter } from 'events';
9
+ import { execSync } from 'child_process';
10
+ import { HampAgentRunner } from './hampagent/runner.js';
11
+ import { loadSessionId, saveSessionId, clearSession } from './hampagent/sessions.js';
12
+ import { existsSync, readFileSync } from 'fs';
13
+ import path from 'path';
14
+
15
+ /** Check that the claude CLI is available */
16
+ function claudeAvailable() {
17
+ try { execSync('which claude', { stdio: 'pipe' }); return true; } catch { return false; }
18
+ }
19
+
20
+ export class HampAgentCLI extends EventEmitter {
21
+ constructor() {
22
+ super();
23
+ this.anthropicApiKey = null; // kept for API compat, not used
24
+ this._runners = new Map(); // agentId -> HampAgentRunner (active tasks only)
25
+ }
26
+
27
+ setAnthropicKey(key) { this.anthropicApiKey = key; }
28
+
29
+ async createAgent(agentId, workDir) {
30
+ return { agentId, workDir };
31
+ }
32
+
33
+ async getAgentIdentity(agentId) {
34
+ return null;
35
+ }
36
+
37
+ cancelAgent(agentId) {
38
+ const runner = this._runners.get(agentId);
39
+ if (runner) { runner.cancel(); this._runners.delete(agentId); return true; }
40
+ return false;
41
+ }
42
+
43
+ isAvailable() { return claudeAvailable(); }
44
+
45
+ async runAgentTask(agentId, task, workDir, sessionId = null, image = null, browserProfile = null, imageWorkDir = null) {
46
+ if (!claudeAvailable()) throw new Error('Hampagent: claude CLI not found — install Claude Code first');
47
+
48
+ // Load prior claude session ID for conversation continuity
49
+ const claudeSessionId = loadSessionId(agentId);
50
+
51
+ // Load AGENTFORGE.md if available in the agent workspace
52
+ let agentforgemd = null;
53
+ const afPath = path.join(workDir, 'AGENTFORGE.md');
54
+ if (existsSync(afPath)) {
55
+ try { agentforgemd = readFileSync(afPath, 'utf8'); } catch {}
56
+ }
57
+
58
+ const runner = new HampAgentRunner();
59
+ runner._agentId = agentId;
60
+ this._runners.set(agentId, runner);
61
+
62
+ // ── Forward runner events to worker.js as standard events ──────────────
63
+
64
+ runner.on('token', (text) => {
65
+ this.emit('agent_output', { agentId, output: text });
66
+ this.emit('agent_alive', { agentId });
67
+ });
68
+
69
+ runner.on('tool_start', ({ tool, label, input }) => {
70
+ this.emit('agent_alive', { agentId });
71
+ this.emit('tool_activity', {
72
+ agentId,
73
+ event: 'tool_start',
74
+ tool,
75
+ description: label,
76
+ toolInput: input,
77
+ });
78
+ });
79
+
80
+ runner.on('tool_end', ({ tool, success, error }) => {
81
+ this.emit('tool_activity', {
82
+ agentId,
83
+ event: 'tool_end',
84
+ tool,
85
+ description: success ? `✓ ${tool}` : `✗ ${tool}: ${error}`,
86
+ });
87
+ });
88
+
89
+ runner.on('image', (base64DataUrl) => {
90
+ this.emit('agent_image', { agentId, image: base64DataUrl });
91
+ });
92
+
93
+ const startTime = Date.now();
94
+
95
+ try {
96
+ const result = await runner.run(task, {
97
+ taskCwd: workDir,
98
+ workDir: imageWorkDir || workDir,
99
+ agentName: this._agentName,
100
+ agentEmoji: this._agentEmoji,
101
+ dashboardUrl: 'https://agentforgeai-production.up.railway.app/dashboard',
102
+ agentforgemd,
103
+ resumeSessionId: claudeSessionId,
104
+ });
105
+
106
+ // Save new claude session ID for next turn (or clear if session was stale)
107
+ if (result.sessionCleared) clearSession(agentId);
108
+ if (result.sessionId) saveSessionId(agentId, result.sessionId);
109
+
110
+ this._runners.delete(agentId);
111
+
112
+ const identity = {
113
+ identityName: this._agentName || agentId,
114
+ identityEmoji: this._agentEmoji || '🦅',
115
+ };
116
+ const duration = Date.now() - startTime;
117
+ this.emit('agent_completed', { agentId, duration, result: { output: result.text }, identity });
118
+ return { success: true, agentId, duration, result: { output: result.text }, identity };
119
+
120
+ } catch (err) {
121
+ this._runners.delete(agentId);
122
+ throw err;
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,415 @@
1
+ import { exec } from 'child_process';
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
3
+ import { EventEmitter } from 'events';
4
+ import path from 'path';
5
+ import { promisify } from 'util';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const execAsync = promisify(exec);
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ const TOOLS = [
12
+ {
13
+ type: 'function',
14
+ function: {
15
+ name: 'bash',
16
+ description: 'Execute a shell command in the working directory. Returns stdout and stderr.',
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ command: { type: 'string', description: 'The shell command to run' }
21
+ },
22
+ required: ['command']
23
+ }
24
+ }
25
+ },
26
+ {
27
+ type: 'function',
28
+ function: {
29
+ name: 'read_file',
30
+ description: 'Read the full contents of a file.',
31
+ parameters: {
32
+ type: 'object',
33
+ properties: {
34
+ path: { type: 'string', description: 'Path to the file (absolute or relative to workdir)' }
35
+ },
36
+ required: ['path']
37
+ }
38
+ }
39
+ },
40
+ {
41
+ type: 'function',
42
+ function: {
43
+ name: 'write_file',
44
+ description: 'Write content to a file, creating it and any missing parent directories.',
45
+ parameters: {
46
+ type: 'object',
47
+ properties: {
48
+ path: { type: 'string', description: 'Path to write (absolute or relative to workdir)' },
49
+ content: { type: 'string', description: 'File content to write' }
50
+ },
51
+ required: ['path', 'content']
52
+ }
53
+ }
54
+ },
55
+ {
56
+ type: 'function',
57
+ function: {
58
+ name: 'list_directory',
59
+ description: 'List files and subdirectories at a path.',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ path: { type: 'string', description: 'Directory path (absolute or relative to workdir)' }
64
+ },
65
+ required: ['path']
66
+ }
67
+ }
68
+ },
69
+ {
70
+ type: 'function',
71
+ function: {
72
+ name: 'web_fetch',
73
+ description: 'Fetch the text content of a URL (first 4000 chars).',
74
+ parameters: {
75
+ type: 'object',
76
+ properties: {
77
+ url: { type: 'string', description: 'URL to fetch' }
78
+ },
79
+ required: ['url']
80
+ }
81
+ }
82
+ }
83
+ ];
84
+
85
+ /**
86
+ * LocalModelAgent — drop-in replacement for OpenClawCLI.
87
+ * Runs an agentic tool-use loop against ANY OpenAI-compatible local model server.
88
+ *
89
+ * Compatible providers (use their OpenAI-compat endpoint):
90
+ * Ollama: http://localhost:11434 (served at /v1/chat/completions)
91
+ * LM Studio: http://localhost:1234
92
+ * Jan.ai: http://localhost:1337
93
+ * llama.cpp: http://localhost:8080
94
+ * vLLM: http://localhost:8000
95
+ * Any other OpenAI-compatible server
96
+ *
97
+ * Exported as OllamaAgent for backward compat.
98
+ */
99
+ export class OllamaAgent extends EventEmitter {
100
+ constructor(baseUrl = 'http://localhost:11434', model = 'llama3.1:8b') {
101
+ super();
102
+ this.baseUrl = baseUrl.replace(/\/$/, '');
103
+ this.model = model;
104
+ this.activeAgents = new Map(); // agentId -> { startTime, task, workDir, abort, proc: null }
105
+ }
106
+
107
+ // ─── Public interface (mirrors OpenClawCLI) ───────────────────────────────
108
+
109
+ async createAgent(agentId, workDir) {
110
+ mkdirSync(workDir, { recursive: true });
111
+
112
+ // Copy AgentForge template files same as OpenClawCLI does
113
+ const bundledTemplateDir = path.join(__dirname, '../../templates/agent');
114
+ const templateDir = existsSync(bundledTemplateDir) ? bundledTemplateDir : '/tmp/agentforge/templates/agent';
115
+
116
+ try {
117
+ if (existsSync(templateDir)) {
118
+ for (const fname of ['MEMORY.md', 'AGENTS.md', 'AGENTFORGE.md']) {
119
+ const src = path.join(templateDir, fname);
120
+ const dst = path.join(workDir, fname);
121
+ if (existsSync(src) && !existsSync(dst)) {
122
+ writeFileSync(dst, readFileSync(src));
123
+ console.log(`📁 [Ollama] Added ${fname}`);
124
+ }
125
+ }
126
+ const memDir = path.join(workDir, 'memory');
127
+ if (!existsSync(memDir)) mkdirSync(memDir, { recursive: true });
128
+ }
129
+ } catch (err) {
130
+ console.warn(`⚠️ [Ollama] Template setup failed: ${err.message}`);
131
+ }
132
+
133
+ console.log(`✓ [Ollama] Agent workspace ready: ${agentId}`);
134
+ return { agentId, workDir };
135
+ }
136
+
137
+ async runAgentTask(agentId, task, workDir, sessionId = null, image = null) {
138
+ const startTime = Date.now();
139
+ const controller = new AbortController();
140
+
141
+ // Fake proc-like object so worker.js pid checks don't crash
142
+ const fakeProc = { pid: null };
143
+ this.activeAgents.set(agentId, { startTime, task, workDir, abort: () => controller.abort(), proc: fakeProc });
144
+
145
+ console.log(`\n🦙 [Ollama/${this.model}] Running agent: ${agentId}`);
146
+ console.log(` Task: ${task}`);
147
+ console.log(` Working dir: ${workDir}`);
148
+
149
+ try {
150
+ // Load conversation history from disk (session persistence)
151
+ const history = this._loadHistory(agentId, workDir, sessionId);
152
+
153
+ const messages = [
154
+ {
155
+ role: 'system',
156
+ content: [
157
+ `You are an AI agent running on AgentForge.ai.`,
158
+ `Your working directory is: ${workDir}`,
159
+ `Use the available tools to complete the task autonomously.`,
160
+ `When you are done, write a clear summary of what you accomplished.`,
161
+ `Do not ask for clarification — make your best judgment and act.`
162
+ ].join('\n')
163
+ },
164
+ ...history,
165
+ { role: 'user', content: task }
166
+ ];
167
+
168
+ let finalContent = '';
169
+ const MAX_TURNS = 25;
170
+
171
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
172
+ if (controller.signal.aborted) break;
173
+
174
+ this.emit('tool_activity', { agentId, event: 'api_call_start', description: `🦙 Calling ${this.model}...` });
175
+
176
+ let response;
177
+ try {
178
+ // OpenAI-compatible endpoint — works with Ollama, LM Studio, Jan, llama.cpp, vLLM, etc.
179
+ response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ signal: controller.signal,
183
+ body: JSON.stringify({
184
+ model: this.model,
185
+ messages,
186
+ tools: TOOLS,
187
+ tool_choice: 'auto',
188
+ stream: false
189
+ })
190
+ });
191
+ } catch (fetchErr) {
192
+ if (fetchErr.name === 'AbortError') break;
193
+ throw new Error(`Cannot reach local model server at ${this.baseUrl}. Is it running? (${fetchErr.message})`);
194
+ }
195
+
196
+ if (!response.ok) {
197
+ const body = await response.text().catch(() => '');
198
+ throw new Error(`Local model error ${response.status}: ${body}`);
199
+ }
200
+
201
+ const data = await response.json();
202
+ // OpenAI-compat wraps in choices[0].message; Ollama native uses data.message
203
+ const message = data.choices?.[0]?.message ?? data.message;
204
+
205
+ this.emit('tool_activity', {
206
+ agentId,
207
+ event: 'api_call_end',
208
+ description: `✅ Ollama responded`
209
+ });
210
+
211
+ messages.push(message);
212
+
213
+ // ── Handle tool calls ──
214
+ if (message.tool_calls && message.tool_calls.length > 0) {
215
+ for (const toolCall of message.tool_calls) {
216
+ if (controller.signal.aborted) break;
217
+
218
+ const { name, arguments: args } = toolCall.function;
219
+ const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
220
+
221
+ this.emit('tool_activity', {
222
+ agentId,
223
+ event: 'tool_start',
224
+ tool: name,
225
+ description: this._toolDesc(name, parsedArgs)
226
+ });
227
+
228
+ console.log(` [${agentId}] 🔧 ${name}: ${JSON.stringify(parsedArgs).slice(0, 120)}`);
229
+
230
+ const result = await this._executeTool(name, parsedArgs, workDir);
231
+
232
+ this.emit('tool_activity', {
233
+ agentId,
234
+ event: 'tool_end',
235
+ tool: name,
236
+ description: `✓ ${name}`
237
+ });
238
+
239
+ messages.push({ role: 'tool', content: String(result) });
240
+ }
241
+ // Loop back — model will respond to the tool results
242
+ continue;
243
+ }
244
+
245
+ // ── No tool calls: this is the final answer ──
246
+ if (message.content) {
247
+ finalContent = message.content;
248
+ // Stream the response in chunks so the UI feels live
249
+ const words = message.content.split(' ');
250
+ const CHUNK_SIZE = 8;
251
+ for (let i = 0; i < words.length; i += CHUNK_SIZE) {
252
+ if (controller.signal.aborted) break;
253
+ const chunk = words.slice(i, i + CHUNK_SIZE).join(' ') + (i + CHUNK_SIZE < words.length ? ' ' : '');
254
+ this.emit('agent_output', { agentId, output: chunk });
255
+ }
256
+ }
257
+ break;
258
+ }
259
+
260
+ // Persist history for next task
261
+ if (finalContent && sessionId) {
262
+ this._saveHistory(agentId, workDir, sessionId, [
263
+ ...history,
264
+ { role: 'user', content: task },
265
+ { role: 'assistant', content: finalContent }
266
+ ]);
267
+ }
268
+
269
+ const duration = Date.now() - startTime;
270
+ this.activeAgents.delete(agentId);
271
+
272
+ this.emit('agent_completed', {
273
+ agentId,
274
+ duration,
275
+ result: { output: finalContent },
276
+ identity: { identityName: agentId, identityEmoji: '🦙' }
277
+ });
278
+
279
+ console.log(`\n✅ [Ollama] Agent ${agentId} completed in ${(duration / 1000).toFixed(2)}s\n`);
280
+ return { success: true, agentId, duration };
281
+
282
+ } catch (err) {
283
+ this.activeAgents.delete(agentId);
284
+
285
+ if (err.name === 'AbortError' || controller.signal.aborted) {
286
+ this.emit('agent_cancelled', { agentId });
287
+ return { success: false, cancelled: true };
288
+ }
289
+
290
+ console.error(`\n❌ [Ollama] Agent ${agentId} failed: ${err.message}`);
291
+ this.emit('agent_failed', { agentId, error: err.message, code: 1 });
292
+ throw err;
293
+ }
294
+ }
295
+
296
+ cancelAgent(agentId) {
297
+ const info = this.activeAgents.get(agentId);
298
+ if (!info) return false;
299
+ info.abort?.();
300
+ this.activeAgents.delete(agentId);
301
+ this.emit('agent_cancelled', { agentId });
302
+ return true;
303
+ }
304
+
305
+ async listAgents() {
306
+ // Ollama agents don't have a registry — return empty
307
+ return [];
308
+ }
309
+
310
+ async getAgentIdentity(agentId) {
311
+ return { identityName: agentId, identityEmoji: '🦙' };
312
+ }
313
+
314
+ getActiveAgents() {
315
+ return Array.from(this.activeAgents.values());
316
+ }
317
+
318
+ // ─── Tool execution ───────────────────────────────────────────────────────
319
+
320
+ async _executeTool(name, args, workDir) {
321
+ try {
322
+ switch (name) {
323
+ case 'bash': {
324
+ const { stdout, stderr } = await execAsync(args.command, {
325
+ cwd: workDir,
326
+ timeout: 60000,
327
+ maxBuffer: 1024 * 1024 * 2 // 2MB
328
+ });
329
+ return (stdout + stderr).trim() || '(no output)';
330
+ }
331
+
332
+ case 'read_file': {
333
+ const fp = this._resolvePath(args.path, workDir);
334
+ return readFileSync(fp, 'utf-8');
335
+ }
336
+
337
+ case 'write_file': {
338
+ const fp = this._resolvePath(args.path, workDir);
339
+ mkdirSync(path.dirname(fp), { recursive: true });
340
+ writeFileSync(fp, args.content, 'utf-8');
341
+ return `Written ${args.content.length} bytes to ${fp}`;
342
+ }
343
+
344
+ case 'list_directory': {
345
+ const dp = this._resolvePath(args.path, workDir);
346
+ const entries = readdirSync(dp);
347
+ return entries.map(e => {
348
+ try {
349
+ return statSync(path.join(dp, e)).isDirectory() ? `${e}/` : e;
350
+ } catch { return e; }
351
+ }).join('\n') || '(empty)';
352
+ }
353
+
354
+ case 'web_fetch': {
355
+ const res = await fetch(args.url, { signal: AbortSignal.timeout(15000) });
356
+ const text = await res.text();
357
+ return text.slice(0, 4000) + (text.length > 4000 ? '\n...(truncated)' : '');
358
+ }
359
+
360
+ default:
361
+ return `Unknown tool: ${name}`;
362
+ }
363
+ } catch (err) {
364
+ return `Error executing ${name}: ${err.message}`;
365
+ }
366
+ }
367
+
368
+ _resolvePath(p, workDir) {
369
+ return path.isAbsolute(p) ? p : path.join(workDir, p);
370
+ }
371
+
372
+ _toolDesc(name, args) {
373
+ switch (name) {
374
+ case 'bash':
375
+ return args.command?.length > 60 ? args.command.slice(0, 60) + '…' : (args.command || 'bash');
376
+ case 'read_file':
377
+ return `Reading ${path.basename(args.path || '')}`;
378
+ case 'write_file':
379
+ return `Writing ${path.basename(args.path || '')}`;
380
+ case 'list_directory':
381
+ return `Listing ${args.path || '.'}`;
382
+ case 'web_fetch': {
383
+ try { return `Fetching ${new URL(args.url).hostname}`; } catch { return 'Fetching URL'; }
384
+ }
385
+ default:
386
+ return name;
387
+ }
388
+ }
389
+
390
+ // ─── History persistence ──────────────────────────────────────────────────
391
+
392
+ _historyPath(workDir, sessionId) {
393
+ return path.join(workDir, `.ollama_history_${sessionId}.json`);
394
+ }
395
+
396
+ _loadHistory(agentId, workDir, sessionId) {
397
+ if (!sessionId) return [];
398
+ try {
399
+ const fp = this._historyPath(workDir, sessionId);
400
+ if (existsSync(fp)) {
401
+ const data = JSON.parse(readFileSync(fp, 'utf-8'));
402
+ // Keep last 20 messages to stay within context
403
+ return data.slice(-20);
404
+ }
405
+ } catch {}
406
+ return [];
407
+ }
408
+
409
+ _saveHistory(agentId, workDir, sessionId, messages) {
410
+ try {
411
+ const fp = this._historyPath(workDir, sessionId);
412
+ writeFileSync(fp, JSON.stringify(messages.slice(-40), null, 2));
413
+ } catch {}
414
+ }
415
+ }