@leverageaiapps/locus 2.2.2 → 2.2.4

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,936 @@
1
+ "use strict";
2
+ /**
3
+ * Core Agent Loop — runs locally on the user's Mac inside Locus.
4
+ * Adapted from container-agent/src/agent-loop.ts.
5
+ *
6
+ * Key differences from container-agent (sandbox):
7
+ * - Memory paths: ~/.leverage/memory/ (not /root/memory/)
8
+ * - Working directory: user-specified or HOME (not /root)
9
+ * - No browser_action tool, no request_human_help tool
10
+ * - No ensureDesktop(), no refreshDesktopIfNeeded()
11
+ * - No R2 file sync for bash output (files already local on Mac)
12
+ * - install_skill: fetches from ClawHub locally → creates via WorkerProxy
13
+ * - Persistent process — no DO eviction recovery needed
14
+ */
15
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ var desc = Object.getOwnPropertyDescriptor(m, k);
18
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
19
+ desc = { enumerable: true, get: function() { return m[k]; } };
20
+ }
21
+ Object.defineProperty(o, k2, desc);
22
+ }) : (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ o[k2] = m[k];
25
+ }));
26
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
27
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
28
+ }) : function(o, v) {
29
+ o["default"] = v;
30
+ });
31
+ var __importStar = (this && this.__importStar) || (function () {
32
+ var ownKeys = function(o) {
33
+ ownKeys = Object.getOwnPropertyNames || function (o) {
34
+ var ar = [];
35
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
36
+ return ar;
37
+ };
38
+ return ownKeys(o);
39
+ };
40
+ return function (mod) {
41
+ if (mod && mod.__esModule) return mod;
42
+ var result = {};
43
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
44
+ __setModuleDefault(result, mod);
45
+ return result;
46
+ };
47
+ })();
48
+ Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.cancelCurrentTask = cancelCurrentTask;
50
+ exports.injectUserMessage = injectUserMessage;
51
+ exports.isRunning = isRunning;
52
+ exports.runAgentLoop = runAgentLoop;
53
+ const promises_1 = require("node:fs/promises");
54
+ const node_os_1 = require("node:os");
55
+ const worker_proxy_1 = require("./worker-proxy");
56
+ const tool_defs_1 = require("./tool-defs");
57
+ const local_tools_1 = require("./local-tools");
58
+ const compaction_1 = require("./compaction");
59
+ const types_1 = require("./types");
60
+ const crypto = __importStar(require("node:crypto"));
61
+ // ─── Memory Path ────────────────────────────────────────────────
62
+ const MEMORY_DIR = `${(0, node_os_1.homedir)()}/.leverage/memory`;
63
+ // ─── State ───────────────────────────────────────────────────────
64
+ let cancelled = false;
65
+ let currentTaskId = null;
66
+ let pendingUserMessages = [];
67
+ function cancelCurrentTask() {
68
+ if (!currentTaskId)
69
+ return false;
70
+ cancelled = true;
71
+ return true;
72
+ }
73
+ function injectUserMessage(message, images) {
74
+ if (!currentTaskId)
75
+ return false;
76
+ pendingUserMessages.push({ message, images });
77
+ return true;
78
+ }
79
+ function isRunning() {
80
+ return currentTaskId !== null;
81
+ }
82
+ // ─── Sequence counter ────────────────────────────────────────────
83
+ let currentSequence = 0;
84
+ function makeEvent(taskId, userId, conversationId, eventType, data) {
85
+ currentSequence++;
86
+ return {
87
+ taskId, userId, conversationId,
88
+ sequence: currentSequence,
89
+ eventType,
90
+ data,
91
+ };
92
+ }
93
+ // Batch emit helper — sends multiple events in one HTTP call
94
+ async function emitEvents(proxy, events) {
95
+ if (events.length === 0)
96
+ return;
97
+ try {
98
+ await proxy.emitEvents(events);
99
+ }
100
+ catch (err) {
101
+ console.error(`[Agent] Failed to emit ${events.length} events: ${err.message}`);
102
+ }
103
+ }
104
+ async function emitEvent(proxy, taskId, userId, conversationId, eventType, data) {
105
+ await emitEvents(proxy, [makeEvent(taskId, userId, conversationId, eventType, data)]);
106
+ }
107
+ // ─── Main Agent Loop ─────────────────────────────────────────────
108
+ async function runAgentLoop(req) {
109
+ const { taskId, userId, sandboxId, message, sessionId, workerUrl, token } = req;
110
+ const conversationId = sessionId || '0';
111
+ const isCronTask = !!(req.cronJobId && req.cronRunId);
112
+ // Reset state
113
+ cancelled = false;
114
+ currentTaskId = taskId;
115
+ currentSequence = 0;
116
+ pendingUserMessages = [];
117
+ // Set working directory for local tools
118
+ const workDir = req.workingDirectory || (0, node_os_1.homedir)();
119
+ (0, local_tools_1.setWorkingDirectory)(workDir);
120
+ const proxy = new worker_proxy_1.WorkerProxy(workerUrl, token, taskId, userId);
121
+ // Chat function — proxied through Worker (API keys stay on Cloudflare)
122
+ const chatFn = (msgs, opts) => proxy.claudeChat(msgs, opts);
123
+ // Hoist messages so catch block can save partial conversation on failure
124
+ const messages = [];
125
+ try {
126
+ // Mark task as running
127
+ await proxy.updateTaskStatus('running');
128
+ // Load context from Worker (conversation history, system prompt, skills)
129
+ // Read local memory files first
130
+ let memoryContent = '';
131
+ let recentNotes = '';
132
+ try {
133
+ memoryContent = await (0, promises_1.readFile)(`${MEMORY_DIR}/MEMORY.md`, 'utf-8').catch(() => '');
134
+ // Read last 2 days of notes
135
+ const today = new Date();
136
+ for (let i = 0; i < 2; i++) {
137
+ const d = new Date(today);
138
+ d.setDate(d.getDate() - i);
139
+ const dateStr = d.toISOString().split('T')[0];
140
+ try {
141
+ const note = await (0, promises_1.readFile)(`${MEMORY_DIR}/${dateStr}.md`, 'utf-8');
142
+ if (note)
143
+ recentNotes += `\n--- ${dateStr} ---\n${note}`;
144
+ }
145
+ catch { }
146
+ }
147
+ }
148
+ catch { }
149
+ const ctx = await proxy.loadContext(sandboxId, conversationId, isCronTask, memoryContent, recentNotes.trim(), req.workingDirectory);
150
+ // Build messages array
151
+ if (ctx.conversationHistory.length > 0) {
152
+ messages.push(...ctx.conversationHistory);
153
+ }
154
+ // Build user message (with optional images)
155
+ const cronPrefix = isCronTask ? '[Scheduled Task Notification]\n\n' : '';
156
+ const userContent = [];
157
+ if (req.images && req.images.length > 0) {
158
+ for (const img of req.images) {
159
+ const match = img.match(/^data:(image\/\w+);base64,(.+)/);
160
+ if (match) {
161
+ userContent.push({ type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } });
162
+ }
163
+ }
164
+ }
165
+ userContent.push({ type: 'text', text: cronPrefix + message });
166
+ messages.push({ role: 'user', content: userContent.length === 1 ? cronPrefix + message : userContent });
167
+ const systemPrompt = ctx.systemPrompt;
168
+ let fullOutput = '';
169
+ // Emit connected event
170
+ await emitEvent(proxy, taskId, userId, conversationId, 'connected', {
171
+ request_id: taskId,
172
+ sandbox_id: sandboxId,
173
+ });
174
+ const loopStartTime = Date.now();
175
+ // ─── Agentic Loop ────────────────────────────────────────
176
+ for (let turn = 0; turn < types_1.MAX_AGENT_TURNS; turn++) {
177
+ if (cancelled) {
178
+ await emitEvent(proxy, taskId, userId, conversationId, 'done', { finish_reason: 'cancelled' });
179
+ break;
180
+ }
181
+ // Check total elapsed time
182
+ if (Date.now() - loopStartTime > types_1.LOOP_TIMEOUT) {
183
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
184
+ content: '\n\n⏰ Task has been running for 15 minutes — auto-stopped.',
185
+ });
186
+ break;
187
+ }
188
+ // Drain pending user messages injected via /continue
189
+ while (pendingUserMessages.length > 0) {
190
+ const pm = pendingUserMessages.shift();
191
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
192
+ content: `\n\n**User:** ${pm.message}\n\n`,
193
+ });
194
+ messages.push({ role: 'user', content: pm.message });
195
+ }
196
+ // Compaction if needed
197
+ if ((0, compaction_1.estimateMessagesTokens)(messages) > 150000) {
198
+ const compacted = await (0, compaction_1.maybeCompactMessages)(messages, chatFn, systemPrompt);
199
+ messages.length = 0;
200
+ messages.push(...compacted);
201
+ (0, compaction_1.repairToolUseResultPairing)(messages);
202
+ }
203
+ // Image context compaction (strip old base64 images)
204
+ if (turn >= 1) {
205
+ (0, compaction_1.compactBrowserContext)(messages, 2);
206
+ }
207
+ // Truncate oversized tool results
208
+ (0, compaction_1.truncateOversizedToolResults)(messages);
209
+ // Repair orphaned tool_use/tool_result blocks
210
+ (0, compaction_1.repairToolUseResultPairing)(messages);
211
+ // Context window guard
212
+ const systemTokens = (0, compaction_1.estimateTokens)(systemPrompt || '');
213
+ const ctxCheck = (0, compaction_1.checkContextWindow)(messages, systemTokens);
214
+ if (!ctxCheck.ok) {
215
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
216
+ content: '\n\n⚠️ Context window is full. Please start a new conversation.',
217
+ });
218
+ break;
219
+ }
220
+ // Emit thinking
221
+ await emitEvent(proxy, taskId, userId, conversationId, 'thinking', {
222
+ message: turn === 0 ? 'Thinking...' : 'Processing results...',
223
+ });
224
+ // Build tool list
225
+ const hasSkills = ctx.hasSkills;
226
+ const skillExclude = hasSkills ? [] : ['get_skill', 'create_skill', 'upload_skill_resource'];
227
+ const cronExclude = isCronTask ? ['save_memory', 'search_memory', 'read_memory', 'update_memory'] : [];
228
+ const excludeTools = [...cronExclude, ...skillExclude];
229
+ const tools = (0, tool_defs_1.buildToolList)(excludeTools);
230
+ const chatOpts = {
231
+ model: req.model || 'claude-sonnet-4-6',
232
+ maxTokens: 16384,
233
+ systemPrompt,
234
+ tools,
235
+ toolChoice: { type: 'auto' },
236
+ };
237
+ // Dynamic API timeout
238
+ const API_TIMEOUT = Math.min(240000 + turn * 15000, 360000);
239
+ // Heartbeat during API call
240
+ const heartbeatInterval = setInterval(async () => {
241
+ try {
242
+ await emitEvent(proxy, taskId, userId, conversationId, 'heartbeat', { turn });
243
+ }
244
+ catch { }
245
+ }, 20000);
246
+ let response;
247
+ let apiRetries = 0;
248
+ const MAX_API_RETRIES = 3;
249
+ while (apiRetries <= MAX_API_RETRIES) {
250
+ // Check remaining loop time BEFORE each API attempt
251
+ const remainingLoopTime = types_1.LOOP_TIMEOUT - (Date.now() - loopStartTime);
252
+ if (remainingLoopTime <= 30000) {
253
+ console.error(`[Agent] Turn ${turn}: Only ${Math.round(remainingLoopTime / 1000)}s remaining, aborting retry`);
254
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
255
+ content: '\n\n⚠️ Task timed out. Progress has been saved.',
256
+ });
257
+ break;
258
+ }
259
+ const effectiveApiTimeout = Math.min(API_TIMEOUT, remainingLoopTime - 5000);
260
+ try {
261
+ // Claude API call proxied through Worker (API keys stay on Cloudflare)
262
+ response = await proxy.claudeChat(messages, chatOpts);
263
+ break;
264
+ }
265
+ catch (apiErr) {
266
+ apiRetries++;
267
+ const errMsg = apiErr.message || '';
268
+ console.error(`[Agent] Turn ${turn}: API failed (attempt ${apiRetries}): ${errMsg}`);
269
+ // Non-retryable errors — break immediately
270
+ if (/incorrect role|roles must alternate/i.test(errMsg)) {
271
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
272
+ content: '\n\n⚠️ Message ordering conflict. Please try again.',
273
+ });
274
+ break;
275
+ }
276
+ if (/image exceeds.*mb|image dimensions exceed/i.test(errMsg)) {
277
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
278
+ content: '\n\n⚠️ Image is too large. Please compress it and try again.',
279
+ });
280
+ break;
281
+ }
282
+ if (apiRetries > MAX_API_RETRIES) {
283
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
284
+ content: '\n\n⚠️ AI service response timed out.',
285
+ });
286
+ break;
287
+ }
288
+ // Detect context overflow vs transient errors
289
+ const isContextOverflow = /too long|context length|token limit|prompt.*exceed|request too large|max.*token/i.test(errMsg);
290
+ // Multi-layer context overflow recovery
291
+ if (isContextOverflow) {
292
+ if (apiRetries === 1) {
293
+ // Layer 1: Aggressive tool result truncation + image strip
294
+ console.log('[Agent] Recovery layer 1: aggressive tool result truncation');
295
+ (0, compaction_1.truncateOversizedToolResults)(messages, 10000);
296
+ (0, compaction_1.compactBrowserContext)(messages, 1);
297
+ }
298
+ else if (apiRetries === 2) {
299
+ // Layer 2: Full compaction via summary
300
+ console.log('[Agent] Recovery layer 2: full compaction');
301
+ try {
302
+ const compacted = await (0, compaction_1.maybeCompactMessages)(messages, chatFn, systemPrompt);
303
+ messages.length = 0;
304
+ messages.push(...compacted);
305
+ }
306
+ catch {
307
+ const compacted = (0, compaction_1.emergencyCompactMessages)(messages);
308
+ messages.length = 0;
309
+ messages.push(...compacted);
310
+ }
311
+ }
312
+ else {
313
+ // Layer 3: Emergency compact (nuclear option)
314
+ console.log('[Agent] Recovery layer 3: emergency compact');
315
+ const compacted = (0, compaction_1.emergencyCompactMessages)(messages);
316
+ messages.length = 0;
317
+ messages.push(...compacted);
318
+ }
319
+ }
320
+ else {
321
+ // Transient error — emergency compact to reduce payload
322
+ const compacted = (0, compaction_1.emergencyCompactMessages)(messages);
323
+ messages.length = 0;
324
+ messages.push(...compacted);
325
+ }
326
+ // Repair pairing after any compaction
327
+ (0, compaction_1.repairToolUseResultPairing)(messages);
328
+ // Exponential backoff with jitter
329
+ const backoffDelay = Math.min(2000 * Math.pow(2, apiRetries - 1), 15000)
330
+ + Math.floor(Math.random() * 1000);
331
+ console.log(`[Agent] Retrying in ${backoffDelay}ms...`);
332
+ await new Promise(r => setTimeout(r, backoffDelay));
333
+ }
334
+ }
335
+ clearInterval(heartbeatInterval);
336
+ if (!response) {
337
+ console.error(`[Agent] Turn ${turn}: No response after retries, breaking`);
338
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', {
339
+ content: '\n\n⚠️ AI service not responding. Please try again.',
340
+ });
341
+ break;
342
+ }
343
+ console.log(`[Agent] Turn ${turn}: stop_reason=${response.stop_reason}, blocks=${response.content.length}, usage=${JSON.stringify(response.usage)}`);
344
+ // Turn 0: retry with tool_choice='any' if text-only
345
+ const hasAnyToolUse = response.content.some((b) => b.type === 'tool_use' || b.type === 'server_tool_use');
346
+ if (turn === 0 && !hasAnyToolUse && response.stop_reason === 'end_turn') {
347
+ console.log('[Agent] Turn 0 text-only, retrying with tool_choice=any');
348
+ const retryHeartbeat = setInterval(async () => {
349
+ try {
350
+ await emitEvent(proxy, taskId, userId, conversationId, 'heartbeat', {});
351
+ }
352
+ catch { }
353
+ }, 20000);
354
+ try {
355
+ const retryResponse = await proxy.claudeChat(messages, {
356
+ ...chatOpts,
357
+ toolChoice: { type: 'any' },
358
+ });
359
+ if (retryResponse.content.length > 0) {
360
+ response = retryResponse;
361
+ }
362
+ else {
363
+ console.log('[Agent] Turn 0 retry returned empty, keeping original text response');
364
+ }
365
+ }
366
+ catch (retryErr) {
367
+ console.error(`[Agent] Turn 0 retry failed (${retryErr.message}), keeping original text response`);
368
+ }
369
+ finally {
370
+ clearInterval(retryHeartbeat);
371
+ }
372
+ }
373
+ // Process content blocks
374
+ const assistantContent = response.content;
375
+ let hasToolUse = false;
376
+ const toolResults = [];
377
+ for (const block of assistantContent) {
378
+ if (cancelled)
379
+ break;
380
+ if (block.type === 'text' && block.text) {
381
+ await emitEvent(proxy, taskId, userId, conversationId, 'text_delta', { content: block.text });
382
+ if (fullOutput.length < 10000)
383
+ fullOutput += block.text;
384
+ }
385
+ else if (block.type === 'tool_use') {
386
+ hasToolUse = true;
387
+ const toolName = block.name || 'unknown';
388
+ const toolInput = block.input || {};
389
+ const toolId = block.id || crypto.randomUUID();
390
+ await emitEvent(proxy, taskId, userId, conversationId, 'tool_call', {
391
+ name: toolName, input: toolInput,
392
+ });
393
+ // Execute tool locally — zero network latency!
394
+ const toolResult = await executeToolLocally(proxy, taskId, userId, sandboxId, conversationId, toolName, toolInput);
395
+ // Truncate output
396
+ let output = toolResult.output;
397
+ if (output.length > types_1.TOOL_OUTPUT_LIMIT) {
398
+ output = output.substring(0, types_1.TOOL_OUTPUT_LIMIT) + '\n... (output truncated)';
399
+ }
400
+ await emitEvent(proxy, taskId, userId, conversationId, 'tool_result', {
401
+ name: toolName, output, is_error: toolResult.isError,
402
+ });
403
+ toolResults.push({ type: 'tool_result', tool_use_id: toolId, content: output });
404
+ }
405
+ else if (block.type === 'server_tool_use') {
406
+ // Server-side tools (web_search, web_fetch) — already executed by Claude API
407
+ await emitEvent(proxy, taskId, userId, conversationId, 'tool_call', {
408
+ name: block.name, input: block.input,
409
+ });
410
+ }
411
+ else if (block.type === 'web_search_tool_result') {
412
+ await emitEvent(proxy, taskId, userId, conversationId, 'tool_result', {
413
+ name: 'web_search', output: `Search completed (${block.content?.length || 0} results)`, is_error: false,
414
+ });
415
+ }
416
+ else if (block.type === 'web_fetch_tool_result') {
417
+ await emitEvent(proxy, taskId, userId, conversationId, 'tool_result', {
418
+ name: 'web_fetch', output: `Fetched: ${block.url || ''}`, is_error: false,
419
+ });
420
+ }
421
+ }
422
+ // Continue or break
423
+ if (response.stop_reason === 'pause_turn') {
424
+ // Server-side tool finished, Claude needs to keep going
425
+ messages.push({ role: 'assistant', content: assistantContent });
426
+ continue;
427
+ }
428
+ if (hasToolUse && toolResults.length > 0) {
429
+ messages.push({ role: 'assistant', content: assistantContent });
430
+ messages.push({ role: 'user', content: toolResults });
431
+ continue;
432
+ }
433
+ // Handle max_tokens: Claude's output was truncated, auto-continue
434
+ if (response.stop_reason === 'max_tokens') {
435
+ console.log(`[Agent] Turn ${turn}: max_tokens hit, auto-continuing`);
436
+ messages.push({ role: 'assistant', content: assistantContent });
437
+ messages.push({ role: 'user', content: 'Continue from where you left off.' });
438
+ continue;
439
+ }
440
+ // No tool use — Claude is done
441
+ break;
442
+ }
443
+ // ─── Post-loop ───────────────────────────────────────────
444
+ // Emit done
445
+ await emitEvent(proxy, taskId, userId, conversationId, 'done', {
446
+ finish_reason: cancelled ? 'cancelled' : 'end_turn',
447
+ });
448
+ // Save conversation turn
449
+ if (!isCronTask) {
450
+ try {
451
+ const summary = buildTurnSummary(messages, message);
452
+ await proxy.saveTurn(sandboxId, conversationId, message, summary);
453
+ }
454
+ catch (err) {
455
+ console.error(`[Agent] Failed to save turn: ${err.message}`);
456
+ }
457
+ }
458
+ // Mark task completed
459
+ await proxy.updateTaskStatus('completed');
460
+ // Finalize cron run if this was a scheduled task
461
+ if (isCronTask && req.cronRunId && req.cronJobId) {
462
+ try {
463
+ await proxy.finalizeCronRun(req.cronRunId, req.cronJobId, sandboxId, 'completed', fullOutput);
464
+ console.log(`[Agent] Finalized cron run ${req.cronRunId} for job ${req.cronJobId}: completed`);
465
+ }
466
+ catch (cronErr) {
467
+ console.error(`[Agent] Failed to finalize cron run: ${cronErr.message}`);
468
+ }
469
+ }
470
+ }
471
+ catch (err) {
472
+ console.error(`[Agent] Task ${taskId} failed: ${err.message}`);
473
+ // Save conversation turn even on failure
474
+ if (!isCronTask && messages.length > 0) {
475
+ try {
476
+ const summary = buildTurnSummary(messages, message)
477
+ + `\n[Task failed: ${err.message}]`;
478
+ await proxy.saveTurn(sandboxId, conversationId, message, summary);
479
+ console.log(`[Agent] Saved partial turn on failure for context continuity`);
480
+ }
481
+ catch (saveErr) {
482
+ console.error(`[Agent] Failed to save turn on failure: ${saveErr.message}`);
483
+ }
484
+ }
485
+ // Emit error event
486
+ try {
487
+ await emitEvent(proxy, taskId, userId, conversationId, 'error', {
488
+ message: `Task failed: ${err.message}`,
489
+ });
490
+ }
491
+ catch { }
492
+ // Mark task failed
493
+ try {
494
+ await proxy.updateTaskStatus('failed', err.message);
495
+ }
496
+ catch { }
497
+ // Finalize cron run as failed
498
+ if (isCronTask && req.cronRunId && req.cronJobId) {
499
+ try {
500
+ await proxy.finalizeCronRun(req.cronRunId, req.cronJobId, sandboxId, 'failed', '', err.message);
501
+ }
502
+ catch { }
503
+ }
504
+ }
505
+ finally {
506
+ currentTaskId = null;
507
+ }
508
+ }
509
+ // ─── Tool Execution ──────────────────────────────────────────────
510
+ async function executeToolLocally(proxy, taskId, userId, sandboxId, conversationId, toolName, toolInput) {
511
+ const TOOL_HARD_TIMEOUT = toolName === 'bash' ? 330000 : 120000;
512
+ // Heartbeat during tool execution
513
+ const toolHeartbeat = setInterval(async () => {
514
+ try {
515
+ await emitEvent(proxy, taskId, userId, conversationId, 'heartbeat', { tool: toolName });
516
+ }
517
+ catch { }
518
+ }, 20000);
519
+ try {
520
+ const result = await Promise.race([
521
+ executeToolInner(proxy, taskId, userId, sandboxId, conversationId, toolName, toolInput),
522
+ // Hard timeout
523
+ new Promise((resolve) => setTimeout(() => resolve({
524
+ output: `Tool ${toolName} timed out after ${TOOL_HARD_TIMEOUT / 1000}s.`,
525
+ isError: true,
526
+ }), TOOL_HARD_TIMEOUT)),
527
+ // Cancellation check
528
+ new Promise((resolve) => {
529
+ const check = setInterval(() => {
530
+ if (cancelled) {
531
+ clearInterval(check);
532
+ resolve({ output: 'Cancelled by user.', isError: true });
533
+ }
534
+ }, 500);
535
+ setTimeout(() => clearInterval(check), TOOL_HARD_TIMEOUT + 1000);
536
+ }),
537
+ ]);
538
+ return result;
539
+ }
540
+ finally {
541
+ clearInterval(toolHeartbeat);
542
+ }
543
+ }
544
+ async function executeToolInner(proxy, taskId, userId, sandboxId, conversationId, toolName, toolInput) {
545
+ // ── Local tools ──
546
+ if (toolName === 'bash') {
547
+ const cmd = (toolInput.command || 'echo "no command"');
548
+ return await (0, local_tools_1.executeBash)(cmd);
549
+ }
550
+ if (toolName === 'write_file') {
551
+ const writePath = toolInput.path || toolInput.file_path;
552
+ return await (0, local_tools_1.executeWriteFile)(writePath, toolInput.content || '');
553
+ }
554
+ if (toolName === 'read_file') {
555
+ return await (0, local_tools_1.executeReadFile)(toolInput.path || toolInput.file_path);
556
+ }
557
+ if (toolName === 'show_file') {
558
+ const showPath = toolInput.path || toolInput.file_path;
559
+ const result = await (0, local_tools_1.executeShowFile)(showPath);
560
+ if (!result.isError) {
561
+ // Upload file to R2 for iOS download
562
+ try {
563
+ const fileData = await (0, local_tools_1.readFileBase64)(showPath);
564
+ if (fileData) {
565
+ const uploadResult = await proxy.uploadToR2(sandboxId, showPath, fileData.base64, fileData.size);
566
+ if (uploadResult.downloadUrl) {
567
+ const fileName = showPath.split('/').pop() || showPath;
568
+ await emitEvent(proxy, taskId, userId, conversationId, 'file_download', {
569
+ filename: fileName,
570
+ download_url: uploadResult.downloadUrl,
571
+ file_path: showPath,
572
+ file_size: fileData.size,
573
+ });
574
+ }
575
+ }
576
+ }
577
+ catch (err) {
578
+ console.error(`[Agent] R2 upload failed for show_file ${showPath}: ${err.message}`);
579
+ }
580
+ }
581
+ return result;
582
+ }
583
+ if (toolName === 'search_files') {
584
+ return await (0, local_tools_1.executeSearchFiles)(toolInput.pattern, toolInput.directory);
585
+ }
586
+ // ── Memory tools (local file + Worker R2 backup) ──
587
+ if (toolName === 'save_memory') {
588
+ try {
589
+ const content = toolInput.content || toolInput.text || '';
590
+ const today = new Date().toISOString().split('T')[0];
591
+ const filePath = `${MEMORY_DIR}/${today}.md`;
592
+ const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
593
+ const entry = `\n## ${timestamp}\n${content}\n`;
594
+ // Append to local file
595
+ await (0, promises_1.mkdir)(MEMORY_DIR, { recursive: true });
596
+ await (0, promises_1.appendFile)(filePath, entry, 'utf-8');
597
+ // Backup to R2
598
+ try {
599
+ const fileContent = await (0, promises_1.readFile)(filePath, 'utf-8');
600
+ await proxy.memoryOp('backup_memory', filePath, fileContent);
601
+ }
602
+ catch { }
603
+ return { output: `Saved to daily log (${today})`, isError: false };
604
+ }
605
+ catch (err) {
606
+ return { output: `Failed to save memory: ${err.message}`, isError: true };
607
+ }
608
+ }
609
+ if (toolName === 'search_memory' || toolName === 'read_memory') {
610
+ try {
611
+ const query = (toolInput.query || toolInput.section || '').toLowerCase();
612
+ const results = [];
613
+ // Search MEMORY.md
614
+ try {
615
+ const mem = await (0, promises_1.readFile)(`${MEMORY_DIR}/MEMORY.md`, 'utf-8');
616
+ if (mem && (toolName === 'read_memory' || mem.toLowerCase().includes(query))) {
617
+ results.push(`--- MEMORY.md ---\n${mem.substring(0, 3000)}`);
618
+ }
619
+ }
620
+ catch { }
621
+ // Search recent daily logs
622
+ const today = new Date();
623
+ for (let i = 0; i < 14; i++) {
624
+ const d = new Date(today);
625
+ d.setDate(d.getDate() - i);
626
+ const dateStr = d.toISOString().split('T')[0];
627
+ try {
628
+ const note = await (0, promises_1.readFile)(`${MEMORY_DIR}/${dateStr}.md`, 'utf-8');
629
+ if (note && note.toLowerCase().includes(query)) {
630
+ results.push(`--- ${dateStr} ---\n${note.substring(0, 1500)}`);
631
+ }
632
+ }
633
+ catch { }
634
+ }
635
+ return { output: results.join('\n\n') || 'No results found.', isError: false };
636
+ }
637
+ catch (err) {
638
+ return { output: `Search failed: ${err.message}`, isError: true };
639
+ }
640
+ }
641
+ if (toolName === 'update_memory') {
642
+ try {
643
+ const content = toolInput.content || '';
644
+ await (0, promises_1.mkdir)(MEMORY_DIR, { recursive: true });
645
+ await (0, promises_1.writeFile)(`${MEMORY_DIR}/MEMORY.md`, content, 'utf-8');
646
+ // Backup to R2
647
+ try {
648
+ await proxy.memoryOp('backup_memory', `${MEMORY_DIR}/MEMORY.md`, content);
649
+ }
650
+ catch { }
651
+ return { output: 'MEMORY.md updated successfully.', isError: false };
652
+ }
653
+ catch (err) {
654
+ return { output: `Failed to update memory: ${err.message}`, isError: true };
655
+ }
656
+ }
657
+ // ── Skill tools (via Worker proxy) ──
658
+ if (toolName === 'get_skill' || toolName === 'create_skill' || toolName === 'upload_skill_resource') {
659
+ try {
660
+ const output = await proxy.skillOp(toolName, toolInput);
661
+ return { output, isError: false };
662
+ }
663
+ catch (err) {
664
+ return { output: `Skill operation failed: ${err.message}`, isError: true };
665
+ }
666
+ }
667
+ // ── install_skill (ClawHub or generic URL) ──
668
+ if (toolName === 'install_skill') {
669
+ return await executeInstallSkill(proxy, userId, toolInput);
670
+ }
671
+ // ── Schedule tool (via Worker proxy) ──
672
+ if (toolName === 'manage_schedule') {
673
+ try {
674
+ const output = await proxy.scheduleOp(sandboxId, toolInput);
675
+ return { output, isError: false };
676
+ }
677
+ catch (err) {
678
+ return { output: `Schedule operation failed: ${err.message}`, isError: true };
679
+ }
680
+ }
681
+ return { output: `Unknown tool: ${toolName}`, isError: true };
682
+ }
683
+ // ─── install_skill Implementation ────────────────────────────────
684
+ async function executeInstallSkill(proxy, userId, toolInput) {
685
+ const sourceUrl = (toolInput.url || '');
686
+ const overrideSkillId = (toolInput.skill_id || '');
687
+ if (!sourceUrl) {
688
+ return { output: 'Error: url is required', isError: true };
689
+ }
690
+ try {
691
+ // ClawHub auto-detection
692
+ const clawHubSlug = extractClawHubSlug(sourceUrl);
693
+ if (clawHubSlug) {
694
+ console.log(`[Agent] install_skill: detected ClawHub URL, slug=${clawHubSlug}`);
695
+ return await installFromClawHub(proxy, clawHubSlug, sourceUrl, overrideSkillId);
696
+ }
697
+ // Generic URL: fetch and auto-detect format
698
+ const resp = await fetch(sourceUrl, {
699
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LeverageAI/1.0)', 'Accept': 'application/json, text/html, */*' },
700
+ });
701
+ const ctype = resp.headers.get('content-type') || '';
702
+ const body = await resp.text();
703
+ if (!resp.ok) {
704
+ return { output: `install_skill: HTTP ${resp.status} fetching ${sourceUrl}`, isError: true };
705
+ }
706
+ if (ctype.includes('json')) {
707
+ // Path A: JSON API
708
+ try {
709
+ const data = JSON.parse(body);
710
+ const skillData = data.skill || data;
711
+ const skillId = overrideSkillId || skillData.skill_id || skillData.id || sourceUrl.split('/').pop()?.replace(/\.\w+$/, '') || 'imported-skill';
712
+ const name = skillData.name || skillData.displayName || skillData.title || skillId;
713
+ const description = skillData.description || skillData.summary || `Skill from ${sourceUrl}`;
714
+ const fullPrompt = skillData.full_prompt || skillData.instructions || skillData.prompt || skillData.content || description;
715
+ if (fullPrompt.length < 20) {
716
+ return { output: `install_skill: JSON fetched but content too short.`, isError: true };
717
+ }
718
+ const output = await proxy.skillOp('create_skill', {
719
+ skill_id: skillId.substring(0, 64),
720
+ name: name.substring(0, 128),
721
+ description: description.substring(0, 1024),
722
+ full_prompt: fullPrompt,
723
+ category: skillData.category || 'custom',
724
+ });
725
+ return { output, isError: false };
726
+ }
727
+ catch (jsonErr) {
728
+ return { output: `install_skill: JSON parse failed (${jsonErr.message})`, isError: true };
729
+ }
730
+ }
731
+ // Path B: HTML/other — strip tags, return for AI to parse
732
+ const text = body
733
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
734
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
735
+ .replace(/<[^>]+>/g, ' ')
736
+ .replace(/\s+/g, ' ')
737
+ .trim()
738
+ .substring(0, 50000);
739
+ return {
740
+ output: `install_skill: Fetched web page. Extract skill info and use create_skill.\n\n<<<EXTERNAL_WEB_CONTENT>>>\nURL: ${sourceUrl}\n---\n${text}\n<<<END_EXTERNAL_WEB_CONTENT>>>`,
741
+ isError: false,
742
+ };
743
+ }
744
+ catch (installErr) {
745
+ return { output: `install_skill error: ${installErr.message}`, isError: true };
746
+ }
747
+ }
748
+ function extractClawHubSlug(url) {
749
+ try {
750
+ const u = new URL(url);
751
+ if (!u.hostname.match(/^(www\.)?(clawhub\.(ai|com))$/))
752
+ return null;
753
+ const segments = u.pathname.split('/').filter(Boolean);
754
+ if (segments.length === 2 && !segments[0].startsWith('api')) {
755
+ return segments[1];
756
+ }
757
+ return null;
758
+ }
759
+ catch {
760
+ return null;
761
+ }
762
+ }
763
+ async function installFromClawHub(proxy, slug, sourceUrl, overrideSkillId) {
764
+ // Step 1: Fetch metadata
765
+ let skillName = slug;
766
+ let description = `Skill from ${sourceUrl}`;
767
+ let version = 'latest';
768
+ let category = 'custom';
769
+ try {
770
+ const metaResp = await fetch(`https://clawhub.ai/api/v1/skills/${slug}`, {
771
+ headers: { 'Accept': 'application/json', 'User-Agent': 'Mozilla/5.0 (compatible; LeverageAI/1.0)' },
772
+ });
773
+ if (metaResp.ok) {
774
+ const meta = await metaResp.json();
775
+ const skill = meta.skill || meta;
776
+ skillName = skill.displayName || skill.name || slug;
777
+ description = skill.summary || skill.description || description;
778
+ version = meta.latestVersion?.version || skill.tags?.latest || version;
779
+ category = skill.category || category;
780
+ }
781
+ }
782
+ catch (e) {
783
+ console.log(`[Agent] install_skill: metadata fetch failed (non-fatal): ${e}`);
784
+ }
785
+ // Step 2: Download ZIP
786
+ const downloadUrl = `https://clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`;
787
+ console.log(`[Agent] install_skill: Downloading: ${downloadUrl}`);
788
+ const dlResp = await fetch(downloadUrl, {
789
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LeverageAI/1.0)' },
790
+ });
791
+ if (!dlResp.ok) {
792
+ return {
793
+ output: `install_skill: ClawHub download failed (HTTP ${dlResp.status}) for slug="${slug}" version="${version}".`,
794
+ isError: true,
795
+ };
796
+ }
797
+ // Step 3: Extract SKILL.md from ZIP
798
+ const zipBuffer = await dlResp.arrayBuffer();
799
+ const skillMd = await extractFileFromZip(zipBuffer, 'SKILL.md');
800
+ if (!skillMd || skillMd.length < 20) {
801
+ return {
802
+ output: `install_skill: Downloaded ZIP but SKILL.md not found or too short (${skillMd?.length || 0} chars).`,
803
+ isError: true,
804
+ };
805
+ }
806
+ // Step 4: Parse frontmatter
807
+ const frontmatterMatch = skillMd.match(/^---\s*\n([\s\S]*?)\n---/);
808
+ if (frontmatterMatch) {
809
+ const fm = frontmatterMatch[1];
810
+ const fmName = fm.match(/^name:\s*(.+)$/m);
811
+ const fmDesc = fm.match(/^description:\s*(.+)$/m);
812
+ if (fmName)
813
+ skillName = fmName[1].trim() || skillName;
814
+ if (fmDesc)
815
+ description = fmDesc[1].trim() || description;
816
+ }
817
+ const skillId = overrideSkillId || slug;
818
+ // Step 5: Create skill via Worker proxy
819
+ try {
820
+ const output = await proxy.skillOp('create_skill', {
821
+ skill_id: skillId.substring(0, 64),
822
+ name: skillName.substring(0, 128),
823
+ description: description.substring(0, 1024),
824
+ full_prompt: skillMd,
825
+ category,
826
+ trigger_pattern: slug.replace(/-/g, '|'),
827
+ });
828
+ return { output, isError: false };
829
+ }
830
+ catch (err) {
831
+ return { output: `install_skill: create failed: ${err.message}`, isError: true };
832
+ }
833
+ }
834
+ /**
835
+ * Extract a named file from a ZIP buffer.
836
+ * Supports stored (uncompressed) and deflated entries.
837
+ * Node.js has zlib built-in for deflate decompression.
838
+ */
839
+ async function extractFileFromZip(buffer, targetName) {
840
+ const view = new DataView(buffer);
841
+ const bytes = new Uint8Array(buffer);
842
+ let offset = 0;
843
+ while (offset + 30 <= bytes.length) {
844
+ // Check local file header signature: PK\x03\x04
845
+ if (view.getUint32(offset, true) !== 0x04034b50)
846
+ break;
847
+ const compressedSize = view.getUint32(offset + 18, true);
848
+ const uncompressedSize = view.getUint32(offset + 22, true);
849
+ const nameLen = view.getUint16(offset + 26, true);
850
+ const extraLen = view.getUint16(offset + 28, true);
851
+ const compressionMethod = view.getUint16(offset + 8, true);
852
+ const nameBytes = bytes.slice(offset + 30, offset + 30 + nameLen);
853
+ const fileName = new TextDecoder().decode(nameBytes);
854
+ const dataStart = offset + 30 + nameLen + extraLen;
855
+ if (fileName === targetName) {
856
+ if (compressionMethod === 0) {
857
+ // Stored (no compression)
858
+ const fileData = bytes.slice(dataStart, dataStart + uncompressedSize);
859
+ return new TextDecoder().decode(fileData);
860
+ }
861
+ else if (compressionMethod === 8) {
862
+ // Deflate — use Node.js zlib
863
+ try {
864
+ const { inflateRawSync } = require('node:zlib');
865
+ const compressed = Buffer.from(bytes.slice(dataStart, dataStart + compressedSize));
866
+ const decompressed = inflateRawSync(compressed);
867
+ return decompressed.toString('utf-8');
868
+ }
869
+ catch (e) {
870
+ console.error(`[Agent] ZIP deflate failed for ${targetName}: ${e}`);
871
+ return null;
872
+ }
873
+ }
874
+ return null;
875
+ }
876
+ offset = dataStart + compressedSize;
877
+ }
878
+ return null;
879
+ }
880
+ // ─── Turn Summary Builder ────────────────────────────────────────
881
+ function buildTurnSummary(messages, originalMessage) {
882
+ const parts = [];
883
+ for (const msg of messages) {
884
+ if (msg.role === 'assistant') {
885
+ if (typeof msg.content === 'string') {
886
+ parts.push(msg.content.substring(0, 500));
887
+ }
888
+ else if (Array.isArray(msg.content)) {
889
+ for (const block of msg.content) {
890
+ const b = block;
891
+ if (b.type === 'text' && b.text) {
892
+ parts.push(b.text.substring(0, 500));
893
+ }
894
+ else if (b.type === 'tool_use') {
895
+ parts.push(`[Used ${b.name}: ${summarizeToolInput(b.name, b.input)}]`);
896
+ }
897
+ }
898
+ }
899
+ }
900
+ else if (msg.role === 'user' && Array.isArray(msg.content)) {
901
+ // Include tool_result content so next turn has full context
902
+ for (const block of msg.content) {
903
+ const b = block;
904
+ if (b.type === 'tool_result') {
905
+ const result = typeof b.content === 'string' ? b.content : JSON.stringify(b.content || '');
906
+ const truncated = result.length > 300 ? result.substring(0, 300) + '...' : result;
907
+ parts.push(`[Result: ${truncated}]`);
908
+ }
909
+ }
910
+ }
911
+ }
912
+ return parts.join('\n').substring(0, 8000);
913
+ }
914
+ function summarizeToolInput(toolName, input) {
915
+ if (!input)
916
+ return '';
917
+ switch (toolName) {
918
+ case 'bash':
919
+ return (input.command || '').substring(0, 150);
920
+ case 'write_file':
921
+ return `${input.path || input.file_path || ''} (${(input.content || '').length} chars)`;
922
+ case 'read_file':
923
+ return input.path || input.file_path || '';
924
+ case 'show_file':
925
+ return input.path || input.file_path || '';
926
+ case 'search_files':
927
+ return `pattern=${input.pattern || ''} dir=${input.directory || ''}`;
928
+ case 'save_memory':
929
+ return (input.content || '').substring(0, 80);
930
+ case 'search_memory':
931
+ return input.query || '';
932
+ default:
933
+ return JSON.stringify(input).substring(0, 120);
934
+ }
935
+ }
936
+ //# sourceMappingURL=agent-loop.js.map