nex-code 0.3.4 → 0.3.7

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/cli/sub-agent.js DELETED
@@ -1,425 +0,0 @@
1
- /**
2
- * cli/sub-agent.js — Sub-Agent Runner
3
- * Spawns parallel sub-agents with their own conversation contexts.
4
- */
5
-
6
- const { callStream, getActiveProviderName, getActiveModelId, getConfiguredProviders, getProvider, getActiveProvider, parseModelSpec } = require('./providers/registry');
7
- const { parseToolArgs } = require('./ollama');
8
- const { filterToolsForModel, getModelTier } = require('./tool-tiers');
9
- const { trackUsage } = require('./costs');
10
- const { MultiProgress, C } = require('./ui');
11
-
12
- const MAX_SUB_ITERATIONS = 15;
13
- const MAX_PARALLEL_AGENTS = 5;
14
- const MAX_CHAT_RETRIES = 3;
15
-
16
- // ─── File Locking ─────────────────────────────────────────────
17
- // Map<filePath, agentId> — allows same agent to re-lock its own files
18
- const lockedFiles = new Map();
19
-
20
- function acquireLock(filePath, agentId) {
21
- const owner = lockedFiles.get(filePath);
22
- if (owner && owner !== agentId) return false;
23
- lockedFiles.set(filePath, agentId);
24
- return true;
25
- }
26
-
27
- function releaseLock(filePath) {
28
- lockedFiles.delete(filePath);
29
- }
30
-
31
- function clearAllLocks() {
32
- lockedFiles.clear();
33
- }
34
-
35
- // ─── Retry Logic ─────────────────────────────────────────────
36
-
37
- function isRetryableError(err) {
38
- const msg = err.message || '';
39
- const code = err.code || '';
40
- // Rate limit
41
- if (msg.includes('429')) return true;
42
- // Server errors
43
- if (msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('504')) return true;
44
- // Network errors
45
- if (code === 'ECONNRESET' || code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ECONNREFUSED') return true;
46
- if (msg.includes('socket disconnected') || msg.includes('TLS') || msg.includes('ECONNRESET')) return true;
47
- if (msg.includes('fetch failed') || msg.includes('ETIMEDOUT') || msg.includes('ENOTFOUND')) return true;
48
- return false;
49
- }
50
-
51
- async function callWithRetry(messages, tools, options) {
52
- let lastError;
53
- for (let attempt = 0; attempt <= MAX_CHAT_RETRIES; attempt++) {
54
- try {
55
- // Use callStream (stream:true) — more reliable than callChat (stream:false)
56
- // with Ollama Cloud. Silently collect the full response via no-op onToken.
57
- return await callStream(messages, tools, { ...options, onToken: () => {} });
58
- } catch (err) {
59
- lastError = err;
60
- if (attempt < MAX_CHAT_RETRIES && isRetryableError(err)) {
61
- const delay = Math.min(2000 * Math.pow(2, attempt), 15000);
62
- await new Promise(r => setTimeout(r, delay).unref());
63
- continue;
64
- }
65
- throw err;
66
- }
67
- }
68
- throw lastError;
69
- }
70
-
71
- // Tools that sub-agents should NOT have access to
72
- const EXCLUDED_TOOLS = new Set(['ask_user', 'task_list', 'spawn_agents']);
73
-
74
- // Tools that need file locking
75
- const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'patch_file']);
76
-
77
- // ─── Task Classification + Model Routing ──────────────────────
78
-
79
- const FAST_PATTERNS = /\b(read|summarize|search|find|list|check|count|inspect|scan)\b/i;
80
- const HEAVY_PATTERNS = /\b(refactor|rewrite|implement|create|architect|design|generate|migrate)\b/i;
81
-
82
- /**
83
- * Classify a task description into a complexity tier.
84
- * @param {string} taskDesc
85
- * @returns {'essential'|'standard'|'full'}
86
- */
87
- function classifyTask(taskDesc) {
88
- if (HEAVY_PATTERNS.test(taskDesc)) return 'full';
89
- if (FAST_PATTERNS.test(taskDesc)) return 'essential';
90
- return 'standard';
91
- }
92
-
93
- /**
94
- * Pick the best available model at a target tier.
95
- * Strongly prefers the active provider — only uses others if active has no match.
96
- * Skips the 'local' provider unless it is the active provider (local may not be running).
97
- * @param {string} targetTier
98
- * @returns {{ provider: string, model: string }|null}
99
- */
100
- function pickModelForTier(targetTier) {
101
- const configured = getConfiguredProviders();
102
- const activeProv = getActiveProviderName();
103
-
104
- // First pass: only the active provider
105
- const activeEntry = configured.find(p => p.name === activeProv);
106
- if (activeEntry) {
107
- for (const m of activeEntry.models) {
108
- if (getModelTier(m.id, activeEntry.name) === targetTier) {
109
- return { provider: activeEntry.name, model: m.id };
110
- }
111
- }
112
- }
113
-
114
- // Second pass: other providers (skip 'local' — it claims isConfigured but may not be running)
115
- for (const p of configured) {
116
- if (p.name === activeProv || p.name === 'local') continue;
117
- for (const m of p.models) {
118
- if (getModelTier(m.id, p.name) === targetTier) {
119
- return { provider: p.name, model: m.id };
120
- }
121
- }
122
- }
123
-
124
- return null;
125
- }
126
-
127
- /**
128
- * Resolve the model for a sub-agent: explicit override or auto-routing.
129
- * @param {{ task: string, model?: string }} agentDef
130
- * @returns {{ provider: string|null, model: string|null, tier: string|null }}
131
- */
132
- function resolveSubAgentModel(agentDef) {
133
- // Explicit LLM override: parse "provider:model" format
134
- if (agentDef.model) {
135
- const { provider, model } = parseModelSpec(agentDef.model);
136
- const prov = provider ? getProvider(provider) : getActiveProvider();
137
- const provName = provider || getActiveProviderName();
138
- if (prov && prov.isConfigured() && (prov.getModel(model) || provName === 'local')) {
139
- const tier = getModelTier(model, provName);
140
- return { provider: provName, model, tier };
141
- }
142
- // Invalid spec → fall through to auto-routing
143
- }
144
-
145
- // Auto-routing: always use the active model (most reliable for tool calling)
146
- // but apply the task's complexity tier for tool filtering (simpler tasks get fewer tools)
147
- const targetTier = classifyTask(agentDef.task);
148
- const activeProviderName = getActiveProviderName();
149
- const activeModel = getActiveModelId();
150
-
151
- if (activeProviderName && activeModel) {
152
- return { provider: activeProviderName, model: activeModel, tier: targetTier };
153
- }
154
-
155
- // Ultimate fallback: use global defaults
156
- return { provider: null, model: null, tier: null };
157
- }
158
-
159
- /**
160
- * Run a single sub-agent to completion.
161
- * @param {{ task: string, context?: string, max_iterations?: number }} agentDef
162
- * @param {{ onUpdate?: (status: string) => void }} callbacks
163
- * @returns {{ task: string, status: 'done'|'failed', result: string, toolsUsed: string[], tokensUsed: { input: number, output: number } }}
164
- */
165
- async function runSubAgent(agentDef, callbacks = {}) {
166
- const maxIter = Math.min(agentDef.max_iterations || 10, MAX_SUB_ITERATIONS);
167
- const agentId = `sub-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
168
- const toolsUsed = [];
169
- const tokensUsed = { input: 0, output: 0 };
170
- const locksHeld = new Set();
171
-
172
- const systemPrompt = `You are a focused sub-agent. Complete this specific task efficiently.
173
-
174
- TASK: ${agentDef.task}
175
- ${agentDef.context ? `\nCONTEXT: ${agentDef.context}` : ''}
176
-
177
- WORKING DIRECTORY: ${process.cwd()}
178
-
179
- RULES:
180
- - Focus only on your assigned task. Be concise and efficient.
181
- - When done, respond with a clear summary of what you did and the result.
182
- - Do not ask questions — make reasonable decisions.
183
- - Use relative paths when possible.
184
-
185
- TOOL STRATEGY:
186
- - Use read_file to read files (not bash cat). Use edit_file/patch_file to modify (not bash sed).
187
- - Use glob to find files by name. Use grep to search contents. Only use bash for shell operations.
188
- - ALWAYS read a file with read_file before editing it. edit_file old_text must match exactly.
189
-
190
- ERROR RECOVERY:
191
- - If edit_file fails with "old_text not found": read the file again, compare, and retry with exact text.
192
- - If bash fails: read the error, fix the root cause, then retry.
193
- - After 2 failed attempts at the same operation, summarize the issue and stop.`;
194
-
195
- const messages = [{ role: 'system', content: systemPrompt }];
196
- messages.push({ role: 'user', content: agentDef.task });
197
-
198
- // Resolve model routing
199
- const routing = resolveSubAgentModel(agentDef);
200
- const agentProvider = routing.provider;
201
- const agentModel = routing.model;
202
- const agentTier = routing.tier;
203
-
204
- // Lazy require to avoid circular dependency (tools.js ↔ sub-agent.js)
205
- const { TOOL_DEFINITIONS, executeTool } = require('./tools');
206
-
207
- // Filter tools: exclude interactive/meta tools, apply tier override
208
- const availableTools = filterToolsForModel(
209
- TOOL_DEFINITIONS.filter(t => !EXCLUDED_TOOLS.has(t.function.name)),
210
- agentTier
211
- );
212
-
213
- // Build options for streaming call (callStream uses active provider automatically)
214
- const chatOptions = {};
215
- if (agentModel) chatOptions.model = agentModel;
216
-
217
- try {
218
- for (let i = 0; i < maxIter; i++) {
219
- const result = await callWithRetry(messages, availableTools, chatOptions);
220
-
221
- // Guard against null/undefined responses
222
- if (!result || typeof result !== 'object') {
223
- throw new Error('Empty or invalid response from provider');
224
- }
225
-
226
- // Track tokens
227
- if (result.usage) {
228
- const inputT = result.usage.prompt_tokens || 0;
229
- const outputT = result.usage.completion_tokens || 0;
230
- tokensUsed.input += inputT;
231
- tokensUsed.output += outputT;
232
- const trackProvider = agentProvider || getActiveProviderName();
233
- const trackModel = agentModel || getActiveModelId();
234
- trackUsage(trackProvider, trackModel, inputT, outputT);
235
- }
236
-
237
- const content = result.content || '';
238
- const tool_calls = result.tool_calls;
239
-
240
- // Build assistant message
241
- const assistantMsg = { role: 'assistant', content: content || '' };
242
- if (tool_calls && tool_calls.length > 0) {
243
- assistantMsg.tool_calls = tool_calls;
244
- }
245
- messages.push(assistantMsg);
246
-
247
- // No tool calls → agent is done
248
- if (!tool_calls || tool_calls.length === 0) {
249
- // Release all locks
250
- for (const fp of locksHeld) releaseLock(fp);
251
-
252
- return {
253
- task: agentDef.task,
254
- status: 'done',
255
- result: content || '(no response)',
256
- toolsUsed,
257
- tokensUsed,
258
- modelSpec: agentProvider && agentModel ? `${agentProvider}:${agentModel}` : null,
259
- };
260
- }
261
-
262
- // Execute tool calls sequentially within sub-agent
263
- for (const tc of tool_calls) {
264
- const fnName = tc.function.name;
265
- const args = parseToolArgs(tc.function.arguments);
266
- const callId = tc.id || `sub-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
267
-
268
- if (!args) {
269
- messages.push({
270
- role: 'tool',
271
- content: `ERROR: Malformed tool arguments for ${fnName}`,
272
- tool_call_id: callId,
273
- });
274
- continue;
275
- }
276
-
277
- // File locking for write tools
278
- if (WRITE_TOOLS.has(fnName) && args.path) {
279
- const path = require('path');
280
- const fp = path.isAbsolute(args.path) ? args.path : path.resolve(process.cwd(), args.path);
281
- if (!acquireLock(fp, agentId)) {
282
- messages.push({
283
- role: 'tool',
284
- content: `ERROR: File '${args.path}' is locked by another sub-agent. Try a different approach or skip this file.`,
285
- tool_call_id: callId,
286
- });
287
- continue;
288
- }
289
- locksHeld.add(fp);
290
- }
291
-
292
- toolsUsed.push(fnName);
293
-
294
- try {
295
- const toolResult = await executeTool(fnName, args, { autoConfirm: true, silent: true });
296
- const safeResult = String(toolResult ?? '');
297
- const truncated = safeResult.length > 20000
298
- ? safeResult.substring(0, 20000) + `\n...(truncated)`
299
- : safeResult;
300
-
301
- messages.push({ role: 'tool', content: truncated, tool_call_id: callId });
302
- } catch (err) {
303
- messages.push({
304
- role: 'tool',
305
- content: `ERROR: ${err.message}`,
306
- tool_call_id: callId,
307
- });
308
- }
309
- }
310
-
311
- if (callbacks.onUpdate) {
312
- callbacks.onUpdate(`step ${i + 1}/${maxIter}`);
313
- }
314
- }
315
-
316
- // Max iterations reached
317
- for (const fp of locksHeld) releaseLock(fp);
318
-
319
- return {
320
- task: agentDef.task,
321
- status: 'done',
322
- result: messages[messages.length - 1]?.content || '(max iterations reached)',
323
- toolsUsed,
324
- tokensUsed,
325
- modelSpec: agentProvider && agentModel ? `${agentProvider}:${agentModel}` : null,
326
- };
327
- } catch (err) {
328
- // Release locks on error
329
- for (const fp of locksHeld) releaseLock(fp);
330
-
331
- return {
332
- task: agentDef.task,
333
- status: 'failed',
334
- result: `Error: ${err.message}`,
335
- toolsUsed,
336
- tokensUsed,
337
- modelSpec: agentProvider && agentModel ? `${agentProvider}:${agentModel}` : null,
338
- };
339
- }
340
- }
341
-
342
- /**
343
- * Execute spawn_agents tool: run multiple sub-agents in parallel.
344
- * @param {{ agents: Array<{ task: string, context?: string, max_iterations?: number }> }} args
345
- * @returns {string} Formatted results for the parent LLM
346
- */
347
- async function executeSpawnAgents(args) {
348
- // Normalize: LLMs may use "prompt"/"description"/"name" instead of "task"
349
- // Strip "model" — LLMs hallucinate model names (e.g. "llama3") that cause 404s.
350
- // Sub-agents always use the active model.
351
- const agents = (args.agents || []).map(a => {
352
- const agent = {
353
- ...a,
354
- task: a.task || a.prompt || a.description || a.name || '(no task)',
355
- };
356
- delete agent.model;
357
- return agent;
358
- });
359
-
360
- if (agents.length === 0) return 'ERROR: No agents specified';
361
- if (agents.length > MAX_PARALLEL_AGENTS) {
362
- return `ERROR: Max ${MAX_PARALLEL_AGENTS} parallel agents allowed, got ${agents.length}`;
363
- }
364
-
365
- const labels = agents.map((a, i) => {
366
- const task = String(a.task || '');
367
- return `Agent ${i + 1}: ${task.substring(0, 50)}${task.length > 50 ? '...' : ''}`;
368
- });
369
- const progress = new MultiProgress(labels);
370
- progress.start();
371
-
372
- try {
373
- const promises = agents.map((agentDef, idx) =>
374
- runSubAgent(agentDef, {
375
- onUpdate: () => {}, // progress is already showing spinner
376
- }).then(result => {
377
- progress.update(idx, result.status === 'done' ? 'done' : 'error');
378
- return result;
379
- }).catch(err => {
380
- progress.update(idx, 'error');
381
- return {
382
- task: agentDef.task,
383
- status: 'failed',
384
- result: `Error: ${err.message}`,
385
- toolsUsed: [],
386
- tokensUsed: { input: 0, output: 0 },
387
- };
388
- })
389
- );
390
-
391
- const results = await Promise.all(promises);
392
- progress.stop();
393
-
394
- // Clear all locks after all agents finish
395
- clearAllLocks();
396
-
397
- // Format results for the parent LLM
398
- const lines = ['Sub-agent results:', ''];
399
- let totalInput = 0;
400
- let totalOutput = 0;
401
-
402
- for (let i = 0; i < results.length; i++) {
403
- const r = results[i];
404
- const statusIcon = r.status === 'done' ? '✓' : '✗';
405
- const modelLabel = r.modelSpec ? ` [${r.modelSpec}]` : '';
406
- lines.push(`${statusIcon} Agent ${i + 1}${modelLabel}: ${r.task}`);
407
- lines.push(` Status: ${r.status}`);
408
- lines.push(` Tools used: ${r.toolsUsed.length > 0 ? r.toolsUsed.join(', ') : 'none'}`);
409
- lines.push(` Result: ${r.result}`);
410
- lines.push('');
411
- totalInput += r.tokensUsed.input;
412
- totalOutput += r.tokensUsed.output;
413
- }
414
-
415
- lines.push(`Total sub-agent tokens: ${totalInput} input + ${totalOutput} output`);
416
-
417
- return lines.join('\n');
418
- } catch (err) {
419
- progress.stop();
420
- clearAllLocks();
421
- return `ERROR: Sub-agent execution failed: ${err.message}`;
422
- }
423
- }
424
-
425
- module.exports = { runSubAgent, executeSpawnAgents, clearAllLocks, classifyTask, pickModelForTier, resolveSubAgentModel, isRetryableError, callWithRetry };
package/cli/tasks.js DELETED
@@ -1,179 +0,0 @@
1
- /**
2
- * cli/tasks.js — Task List Management
3
- * Create, update, and render task lists for complex multi-step operations.
4
- */
5
-
6
- const { C } = require('./ui');
7
-
8
- // Active task list state
9
- let taskListName = '';
10
- let tasks = [];
11
- let taskIdCounter = 0;
12
-
13
- // onChange callback for live display integration
14
- let _onChange = null;
15
-
16
- /**
17
- * Register a callback fired on task list changes.
18
- * @param {function|null} fn - Callback: fn(event, data) where event is 'create'|'update'|'clear'
19
- */
20
- function setOnChange(fn) {
21
- _onChange = fn;
22
- }
23
-
24
- /**
25
- * Create a new task list with a name and array of task descriptions.
26
- * @param {string} name - Name/title for the task list
27
- * @param {Array<{description: string, depends_on?: string[]}>} taskDefs
28
- * @returns {Array<object>} Created tasks
29
- */
30
- function createTasks(name, taskDefs) {
31
- taskListName = name;
32
- tasks = [];
33
- taskIdCounter = 0;
34
-
35
- for (const def of taskDefs) {
36
- taskIdCounter++;
37
- const id = `t${taskIdCounter}`;
38
- tasks.push({
39
- id,
40
- description: def.description || def.title || def.name || def.task || `Task ${taskIdCounter}`,
41
- status: 'pending',
42
- dependsOn: def.depends_on || [],
43
- result: null,
44
- });
45
- }
46
-
47
- const snapshot = tasks.map(t => ({ ...t }));
48
- if (_onChange) _onChange('create', { name, tasks: snapshot });
49
- return snapshot;
50
- }
51
-
52
- /**
53
- * Update a task's status and optionally set its result.
54
- * @param {string} id - Task ID (e.g. 't1')
55
- * @param {string} status - 'in_progress' | 'done' | 'failed'
56
- * @param {string} [result] - Summary of the result
57
- * @returns {object|null} Updated task or null if not found
58
- */
59
- function updateTask(id, status, result) {
60
- const task = tasks.find(t => t.id === id);
61
- if (!task) return null;
62
-
63
- task.status = status;
64
- if (result !== undefined) task.result = result;
65
-
66
- if (_onChange) _onChange('update', { id, status, result });
67
- return { ...task };
68
- }
69
-
70
- /**
71
- * Get the current task list.
72
- * @returns {{ name: string, tasks: Array<object> }}
73
- */
74
- function getTaskList() {
75
- return {
76
- name: taskListName,
77
- tasks: tasks.map(t => ({ ...t })),
78
- };
79
- }
80
-
81
- /**
82
- * Clear all tasks.
83
- */
84
- function clearTasks() {
85
- taskListName = '';
86
- tasks = [];
87
- taskIdCounter = 0;
88
- if (_onChange) _onChange('clear', {});
89
- }
90
-
91
- /**
92
- * Get tasks that are ready to run (pending + all dependencies done).
93
- * @returns {Array<object>}
94
- */
95
- function getReadyTasks() {
96
- return tasks.filter(t => {
97
- if (t.status !== 'pending') return false;
98
- if (t.dependsOn.length === 0) return true;
99
- return t.dependsOn.every(depId => {
100
- const dep = tasks.find(d => d.id === depId);
101
- return dep && dep.status === 'done';
102
- });
103
- });
104
- }
105
-
106
- /**
107
- * Render the task list for terminal display.
108
- * @returns {string}
109
- */
110
- function renderTaskList() {
111
- if (tasks.length === 0) return `${C.dim}No active tasks${C.reset}`;
112
-
113
- const lines = [];
114
-
115
- if (taskListName) {
116
- lines.push(` ${C.bold}${C.cyan}Tasks: ${taskListName}${C.reset}`);
117
- lines.push(` ${C.dim}${'─'.repeat(40)}${C.reset}`);
118
- }
119
-
120
- for (const t of tasks) {
121
- let icon, color;
122
- switch (t.status) {
123
- case 'done':
124
- icon = '✓';
125
- color = C.green;
126
- break;
127
- case 'in_progress':
128
- icon = '→';
129
- color = C.cyan;
130
- break;
131
- case 'failed':
132
- icon = '✗';
133
- color = C.red;
134
- break;
135
- default:
136
- icon = '·';
137
- color = C.dim;
138
- }
139
-
140
- const deps = t.dependsOn.length > 0 ? ` ${C.dim}(after: ${t.dependsOn.join(', ')})${C.reset}` : '';
141
- const status = `[${t.status}]`;
142
- const desc = t.description.length > 50 ? t.description.substring(0, 47) + '...' : t.description;
143
-
144
- lines.push(` ${color}${icon}${C.reset} ${C.bold}${t.id}${C.reset} ${desc.padEnd(40)} ${color}${status}${C.reset}${deps}`);
145
-
146
- if (t.result && t.status === 'done') {
147
- const shortResult = t.result.length > 60 ? t.result.substring(0, 57) + '...' : t.result;
148
- lines.push(` ${C.dim}→ ${shortResult}${C.reset}`);
149
- }
150
- }
151
-
152
- // Summary
153
- const done = tasks.filter(t => t.status === 'done').length;
154
- const failed = tasks.filter(t => t.status === 'failed').length;
155
- const total = tasks.length;
156
- lines.push(` ${C.dim}${'─'.repeat(40)}${C.reset}`);
157
- lines.push(` ${C.dim}${done}/${total} done${failed > 0 ? `, ${failed} failed` : ''}${C.reset}`);
158
-
159
- return lines.join('\n');
160
- }
161
-
162
- /**
163
- * Check if there are active (non-done/failed) tasks.
164
- * @returns {boolean}
165
- */
166
- function hasActiveTasks() {
167
- return tasks.length > 0 && tasks.some(t => t.status === 'pending' || t.status === 'in_progress');
168
- }
169
-
170
- module.exports = {
171
- createTasks,
172
- updateTask,
173
- getTaskList,
174
- clearTasks,
175
- getReadyTasks,
176
- renderTaskList,
177
- setOnChange,
178
- hasActiveTasks,
179
- };