pelulu-cli 1.0.5 → 1.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,276 @@
1
+ /**
2
+ * AgentLoop — Core observe→think→act cycle
3
+ *
4
+ * Handles:
5
+ * - Plain text responses from XiaoZhi (via llm:text)
6
+ * - MCP tool calls from XiaoZhi (via mcp:tool_call)
7
+ */
8
+ import { bus } from '../core/event-bus.js';
9
+ import { debug } from '../core/logger.js';
10
+
11
+ export const AgentState = {
12
+ IDLE: 'idle',
13
+ THINKING: 'thinking',
14
+ ACTING: 'acting',
15
+ FINISHED: 'finished',
16
+ ERROR: 'error',
17
+ };
18
+
19
+ export class AgentLoop {
20
+ #state = AgentState.IDLE;
21
+ #iteration = 0;
22
+ #maxIterations;
23
+ #abortController = null;
24
+ #history = [];
25
+
26
+ constructor({ maxIterations = 50 } = {}) {
27
+ this.#maxIterations = maxIterations;
28
+ }
29
+
30
+ get state() { return this.#state; }
31
+ get iteration() { return this.#iteration; }
32
+ get history() { return [...this.#history]; }
33
+
34
+ #setState(s) {
35
+ const old = this.#state;
36
+ this.#state = s;
37
+ debug('agent', `${old} → ${s}`);
38
+ bus.emit('agent:state', { from: old, to: s });
39
+ }
40
+
41
+ /**
42
+ * Run agent loop for a user prompt
43
+ */
44
+ async run(userPrompt, { llm, tools, sandbox, confirm }) {
45
+ this.#iteration = 0;
46
+ this.#abortController = new AbortController();
47
+ this.#history = [{ role: 'user', content: userPrompt, ts: Date.now() }];
48
+ this._promptSent = false;
49
+
50
+ try {
51
+ // Send prompt only once at the start
52
+ await this.#sendOnce(llm);
53
+
54
+ while (this.#iteration < this.#maxIterations) {
55
+ this.#iteration++;
56
+ this.#setState(AgentState.THINKING);
57
+
58
+ // Emit progress
59
+ bus.emit('agent:progress', {
60
+ iteration: this.#iteration,
61
+ maxIterations: this.#maxIterations,
62
+ state: 'thinking',
63
+ message: `Thinking... (iteration ${this.#iteration})`,
64
+ });
65
+
66
+ if (this.#abortController.signal.aborted) {
67
+ this.#setState(AgentState.FINISHED);
68
+ return { success: true, result: 'Aborted', iterations: this.#iteration };
69
+ }
70
+
71
+ // Wait for response (text or tool call) — does NOT send prompt again
72
+ let response;
73
+ try {
74
+ response = await this.#waitForResponse();
75
+ } catch (err) {
76
+ if (err.message.includes('Timeout')) {
77
+ bus.emit('agent:progress', {
78
+ iteration: this.#iteration,
79
+ state: 'timeout',
80
+ message: 'XiaoZhi not responding, retrying...',
81
+ });
82
+ try {
83
+ response = await this.#waitForResponse();
84
+ } catch (err2) {
85
+ this.#setState(AgentState.FINISHED);
86
+ return { success: false, result: 'Timeout: XiaoZhi did not respond after 2 attempts', iterations: this.#iteration };
87
+ }
88
+ } else {
89
+ throw err;
90
+ }
91
+ }
92
+
93
+ if (!response) {
94
+ this.#setState(AgentState.FINISHED);
95
+ return { success: false, result: 'No response', iterations: this.#iteration };
96
+ }
97
+
98
+ // Handle text response (final answer)
99
+ if (response.type === 'text') {
100
+ this.#history.push({ role: 'assistant', content: response.content, ts: Date.now() });
101
+ this.#setState(AgentState.FINISHED);
102
+ bus.emit('agent:progress', {
103
+ iteration: this.#iteration,
104
+ state: 'done',
105
+ message: 'Done!',
106
+ });
107
+ return { success: true, result: response.content, iterations: this.#iteration };
108
+ }
109
+
110
+ // Handle tool call — wait for MQTT client to execute it, don't double-execute
111
+ if (response.type === 'tool_call') {
112
+ this.#setState(AgentState.ACTING);
113
+
114
+ bus.emit('agent:progress', {
115
+ iteration: this.#iteration,
116
+ state: 'tool',
117
+ tool: response.name,
118
+ action: response.args?.action,
119
+ message: `Executing ${response.name}.${response.args?.action || ''}...`,
120
+ });
121
+
122
+ // Wait for the tool result from MQTT client (already executed there)
123
+ const result = await this.#waitForToolResult(response);
124
+
125
+ this.#history.push({ role: 'tool', name: response.name, content: JSON.stringify(result), ts: Date.now() });
126
+
127
+ bus.emit('agent:progress', {
128
+ iteration: this.#iteration,
129
+ state: 'tool_done',
130
+ tool: response.name,
131
+ message: `${response.name} done, waiting for next response...`,
132
+ });
133
+
134
+ debug('agent', `Tool done, waiting for next response...`);
135
+ }
136
+ }
137
+
138
+ this.#setState(AgentState.FINISHED);
139
+ return { success: false, result: `Max iterations (${this.#maxIterations}) reached`, iterations: this.#iteration };
140
+
141
+ } catch (err) {
142
+ this.#setState(AgentState.ERROR);
143
+ bus.emit('agent:error', { error: err.message, iteration: this.#iteration });
144
+ return { success: false, result: `Error: ${err.message}`, iterations: this.#iteration };
145
+ }
146
+ }
147
+
148
+ abort() {
149
+ this.#abortController?.abort();
150
+ }
151
+
152
+ /**
153
+ * Send prompt to XiaoZhi — only once per run
154
+ */
155
+ async #sendOnce(llm) {
156
+ if (this._promptSent) return;
157
+ this._promptSent = true;
158
+ debug('agent', `Sending prompt: ${this.#history[0].content}`);
159
+ await llm.sendPrompt(this.#history[0].content);
160
+ }
161
+
162
+ /**
163
+ * Wait for response from XiaoZhi (text or MCP tool call)
164
+ * Does NOT send prompt — that's done once in #sendOnce
165
+ */
166
+ #waitForResponse() {
167
+ return new Promise((resolve, reject) => {
168
+ let resolved = false;
169
+ let buffer = '';
170
+ let timer = null;
171
+ let gotToolCall = false;
172
+
173
+ const cleanup = () => {
174
+ if (timer) clearTimeout(timer);
175
+ bus.off('llm:text', onText);
176
+ bus.off('mcp:tool_call', onTool);
177
+ };
178
+
179
+ const onText = (text) => {
180
+ if (resolved || gotToolCall) return;
181
+ buffer += text;
182
+
183
+ bus.emit('agent:progress', {
184
+ state: 'receiving',
185
+ message: `Receiving: ${buffer.length} chars...`,
186
+ });
187
+
188
+ if (timer) clearTimeout(timer);
189
+ timer = setTimeout(() => {
190
+ if (!resolved && !gotToolCall && buffer) {
191
+ resolved = true;
192
+ cleanup();
193
+ resolve({ type: 'text', content: buffer.trim() });
194
+ }
195
+ }, 2000);
196
+ };
197
+
198
+ const onTool = (data) => {
199
+ if (resolved) return;
200
+ gotToolCall = true;
201
+ resolved = true;
202
+ cleanup();
203
+ resolve({ type: 'tool_call', ...data });
204
+ };
205
+
206
+ // Timeout after 60s
207
+ const timeout = setTimeout(() => {
208
+ if (!resolved) {
209
+ resolved = true;
210
+ cleanup();
211
+ reject(new Error('Timeout: XiaoZhi not responding'));
212
+ }
213
+ }, 60000);
214
+
215
+ // Abort handler
216
+ this.#abortController.signal.addEventListener('abort', () => {
217
+ if (!resolved) {
218
+ resolved = true;
219
+ cleanup();
220
+ clearTimeout(timeout);
221
+ reject(new Error('AbortError'));
222
+ }
223
+ }, { once: true });
224
+
225
+ bus.on('llm:text', onText);
226
+ bus.on('mcp:tool_call', onTool);
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Wait for tool result from MQTT client (avoids double-execution)
232
+ * Falls back to direct execution if MQTT doesn't respond in time
233
+ */
234
+ #waitForToolResult(call) {
235
+ return new Promise((resolve) => {
236
+ let resolved = false;
237
+
238
+ const onResult = ({ name, args, result }) => {
239
+ if (resolved) return;
240
+ if (name === call.name && args?.action === call.args?.action) {
241
+ resolved = true;
242
+ bus.off('mcp:tool_result', onResult);
243
+ clearTimeout(fallbackTimer);
244
+ debug('agent', `Got tool result from MQTT: ${name}`);
245
+ resolve(result);
246
+ }
247
+ };
248
+
249
+ // Fallback: if MQTT doesn't respond in 10s, execute directly
250
+ const fallbackTimer = setTimeout(async () => {
251
+ if (resolved) return;
252
+ resolved = true;
253
+ bus.off('mcp:tool_result', onResult);
254
+ debug('agent', `Tool result timeout, executing directly: ${call.name}`);
255
+ try {
256
+ const result = await this.#execToolDirect(call);
257
+ resolve(result);
258
+ } catch (err) {
259
+ resolve({ isError: true, content: [{ type: 'text', text: err.message }] });
260
+ }
261
+ }, 10000);
262
+
263
+ bus.on('mcp:tool_result', onResult);
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Direct tool execution (fallback only)
269
+ */
270
+ async #execToolDirect(call) {
271
+ return { isError: true, content: [{ type: 'text', text: 'Tool execution via MQTT timed out' }] };
272
+ }
273
+
274
+ // Tool execution removed — handled by MQTT client
275
+ // Agent loop only waits for results via mcp:tool_result events
276
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * ContextBuilder — Enhanced workspace context (OpenHands-style)
3
+ *
4
+ * Builds rich context about the workspace including:
5
+ * - Git status and history
6
+ * - Project type and structure
7
+ * - Recent files and changes
8
+ * - Dependencies and configuration
9
+ * - Runtime environment
10
+ */
11
+ import { readFile, readdir, stat } from 'fs/promises';
12
+ import { existsSync } from 'fs';
13
+ import { join, relative, extname } from 'path';
14
+ import { exec } from 'child_process';
15
+ import { getConfig } from '../core/config.js';
16
+
17
+ function run(cmd, cwd, timeout = 5000) {
18
+ return new Promise((resolve) => {
19
+ exec(cmd, { cwd, timeout, maxBuffer: 256 * 1024 }, (_, stdout) => resolve(stdout?.trim() || ''));
20
+ });
21
+ }
22
+
23
+ export class ContextBuilder {
24
+ #cwd;
25
+ #cache = new Map();
26
+ #cacheTTL = 30000; // 30 seconds
27
+
28
+ constructor(cwd) {
29
+ this.#cwd = cwd || getConfig().agent?.workspace || process.cwd();
30
+ }
31
+
32
+ /**
33
+ * Build full context
34
+ */
35
+ async build() {
36
+ const sections = await Promise.all([
37
+ this.#buildGitContext(),
38
+ this.#buildProjectContext(),
39
+ this.#buildRuntimeContext(),
40
+ this.#buildFileSystemContext(),
41
+ ]);
42
+
43
+ return sections.filter(Boolean).join('\n\n');
44
+ }
45
+
46
+ /**
47
+ * Build git context
48
+ */
49
+ async #buildGitContext() {
50
+ if (!existsSync(join(this.#cwd, '.git'))) return null;
51
+
52
+ const cacheKey = 'git';
53
+ const cached = this.#getCache(cacheKey);
54
+ if (cached) return cached;
55
+
56
+ const [branch, status, lastCommits, remotes] = await Promise.all([
57
+ run('git rev-parse --abbrev-ref HEAD', this.#cwd),
58
+ run('git status --porcelain', this.#cwd),
59
+ run('git log --oneline -5', this.#cwd),
60
+ run('git remote -v', this.#cwd),
61
+ ]);
62
+
63
+ const changes = status.split('\n').filter(Boolean);
64
+ const changeSummary = changes.length > 0
65
+ ? `${changes.length} uncommitted change(s):\n${changes.slice(0, 10).map(c => ` ${c}`).join('\n')}${changes.length > 10 ? `\n ... and ${changes.length - 10} more` : ''}`
66
+ : 'Clean working tree';
67
+
68
+ const sections = [
69
+ `## Git`,
70
+ `Branch: ${branch}`,
71
+ changeSummary,
72
+ ];
73
+
74
+ if (lastCommits) {
75
+ sections.push(`Recent commits:\n${lastCommits}`);
76
+ }
77
+
78
+ if (remotes) {
79
+ const uniqueRemotes = [...new Set(remotes.split('\n').map(r => r.split('\t')[0]).filter(Boolean))];
80
+ if (uniqueRemotes.length > 0) {
81
+ sections.push(`Remotes: ${uniqueRemotes.join(', ')}`);
82
+ }
83
+ }
84
+
85
+ const result = sections.join('\n');
86
+ this.#setCache(cacheKey, result);
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Build project context
92
+ */
93
+ async #buildProjectContext() {
94
+ const cacheKey = 'project';
95
+ const cached = this.#getCache(cacheKey);
96
+ if (cached) return cached;
97
+
98
+ const type = await this.#detectProjectType();
99
+ const sections = [`## Project`, `Type: ${type}`];
100
+
101
+ // Package.json (Node.js)
102
+ if (type === 'node' || existsSync(join(this.#cwd, 'package.json'))) {
103
+ try {
104
+ const pkg = JSON.parse(await readFile(join(this.#cwd, 'package.json'), 'utf-8'));
105
+ if (pkg.name) sections.push(`Name: ${pkg.name}@${pkg.version || '?'}`);
106
+ if (pkg.scripts) {
107
+ const scripts = Object.keys(pkg.scripts);
108
+ sections.push(`Scripts: ${scripts.slice(0, 10).join(', ')}${scripts.length > 10 ? '...' : ''}`);
109
+ }
110
+ if (pkg.dependencies) {
111
+ const deps = Object.keys(pkg.dependencies);
112
+ sections.push(`Dependencies: ${deps.length} (${deps.slice(0, 5).join(', ')}${deps.length > 5 ? '...' : ''})`);
113
+ }
114
+ } catch {}
115
+ }
116
+
117
+ // pyproject.toml (Python)
118
+ if (existsSync(join(this.#cwd, 'pyproject.toml'))) {
119
+ try {
120
+ const content = await readFile(join(this.#cwd, 'pyproject.toml'), 'utf-8');
121
+ const nameMatch = content.match(/name\s*=\s*"([^"]+)"/);
122
+ if (nameMatch) sections.push(`Name: ${nameMatch[1]}`);
123
+ } catch {}
124
+ }
125
+
126
+ // README
127
+ if (existsSync(join(this.#cwd, 'README.md'))) {
128
+ try {
129
+ const readme = await readFile(join(this.#cwd, 'README.md'), 'utf-8');
130
+ const firstPara = readme.split('\n\n').find(p => p.trim() && !p.startsWith('#'));
131
+ if (firstPara) {
132
+ sections.push(`Description: ${firstPara.trim().slice(0, 200)}`);
133
+ }
134
+ } catch {}
135
+ }
136
+
137
+ const result = sections.join('\n');
138
+ this.#setCache(cacheKey, result);
139
+ return result;
140
+ }
141
+
142
+ /**
143
+ * Build runtime context
144
+ */
145
+ async #buildRuntimeContext() {
146
+ const [nodeVer, npmVer, osInfo] = await Promise.all([
147
+ run('node --version', this.#cwd),
148
+ run('npm --version', this.#cwd),
149
+ run('uname -a', this.#cwd),
150
+ ]);
151
+
152
+ return [
153
+ `## Runtime`,
154
+ `Node: ${nodeVer}`,
155
+ `npm: ${npmVer}`,
156
+ `OS: ${osInfo}`,
157
+ `Working Directory: ${this.#cwd}`,
158
+ ].join('\n');
159
+ }
160
+
161
+ /**
162
+ * Build file system context (recent files, structure)
163
+ */
164
+ async #buildFileSystemContext() {
165
+ const cacheKey = 'filesystem';
166
+ const cached = this.#getCache(cacheKey);
167
+ if (cached) return cached;
168
+
169
+ const sections = ['## Workspace Structure'];
170
+
171
+ // Get top-level directory listing
172
+ try {
173
+ const entries = await readdir(this.#cwd, { withFileTypes: true });
174
+ const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
175
+ const files = entries.filter(e => e.isFile()).map(e => e.name);
176
+
177
+ if (dirs.length > 0) {
178
+ sections.push(`Directories: ${dirs.slice(0, 15).join(', ')}${dirs.length > 15 ? '...' : ''}`);
179
+ }
180
+ if (files.length > 0) {
181
+ sections.push(`Files: ${files.slice(0, 15).join(', ')}${files.length > 15 ? '...' : ''}`);
182
+ }
183
+ } catch {}
184
+
185
+ // Recently modified files (via git)
186
+ if (existsSync(join(this.#cwd, '.git'))) {
187
+ const recentFiles = await run('git diff --name-only -10', this.#cwd);
188
+ if (recentFiles) {
189
+ sections.push(`Recently modified:\n${recentFiles}`);
190
+ }
191
+ }
192
+
193
+ const result = sections.join('\n');
194
+ this.#setCache(cacheKey, result);
195
+ return result;
196
+ }
197
+
198
+ /**
199
+ * Detect project type
200
+ */
201
+ async #detectProjectType() {
202
+ const checks = [
203
+ ['package.json', 'node'],
204
+ ['requirements.txt', 'python'],
205
+ ['pyproject.toml', 'python'],
206
+ ['Cargo.toml', 'rust'],
207
+ ['go.mod', 'go'],
208
+ ['CMakeLists.txt', 'cmake'],
209
+ ['Makefile', 'make'],
210
+ ['pom.xml', 'java'],
211
+ ['build.gradle', 'gradle'],
212
+ ['Gemfile', 'ruby'],
213
+ ['mix.exs', 'elixir'],
214
+ ['composer.json', 'php'],
215
+ ];
216
+
217
+ for (const [file, type] of checks) {
218
+ if (existsSync(join(this.#cwd, file))) return type;
219
+ }
220
+
221
+ // Check for common file types
222
+ try {
223
+ const files = await readdir(this.#cwd);
224
+ const exts = files.map(f => extname(f).toLowerCase());
225
+ if (exts.includes('.py')) return 'python';
226
+ if (exts.includes('.js') || exts.includes('.ts')) return 'node';
227
+ if (exts.includes('.rs')) return 'rust';
228
+ if (exts.includes('.go')) return 'go';
229
+ } catch {}
230
+
231
+ return 'unknown';
232
+ }
233
+
234
+ /**
235
+ * Get file tree for a specific directory
236
+ */
237
+ async getFileTree(dir, maxDepth = 3, currentDepth = 0) {
238
+ if (currentDepth >= maxDepth) return [];
239
+
240
+ const entries = [];
241
+ try {
242
+ const items = await readdir(dir, { withFileTypes: true });
243
+ for (const item of items) {
244
+ if (item.name.startsWith('.') || item.name === 'node_modules') continue;
245
+
246
+ const fullPath = join(dir, item.name);
247
+ const relPath = relative(this.#cwd, fullPath);
248
+
249
+ if (item.isDirectory()) {
250
+ entries.push({ path: relPath, type: 'dir' });
251
+ const children = await this.getFileTree(fullPath, maxDepth, currentDepth + 1);
252
+ entries.push(...children);
253
+ } else {
254
+ const s = await stat(fullPath).catch(() => null);
255
+ entries.push({ path: relPath, type: 'file', size: s?.size || 0 });
256
+ }
257
+ }
258
+ } catch {}
259
+
260
+ return entries;
261
+ }
262
+
263
+ /**
264
+ * Read a specific file with context
265
+ */
266
+ async readFileWithContext(filePath) {
267
+ const content = await readFile(filePath, 'utf-8');
268
+ const ext = extname(filePath).slice(1);
269
+ const lines = content.split('\n');
270
+
271
+ return {
272
+ path: filePath,
273
+ content,
274
+ lines: lines.length,
275
+ language: this.#detectLanguage(ext),
276
+ size: content.length,
277
+ };
278
+ }
279
+
280
+ #detectLanguage(ext) {
281
+ const map = {
282
+ js: 'javascript', mjs: 'javascript', jsx: 'javascript',
283
+ ts: 'typescript', tsx: 'typescript',
284
+ py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
285
+ java: 'java', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
286
+ sh: 'shell', bash: 'shell', zsh: 'shell',
287
+ json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
288
+ md: 'markdown', html: 'html', css: 'css', sql: 'sql',
289
+ };
290
+ return map[ext] || 'unknown';
291
+ }
292
+
293
+ // Simple cache
294
+ #getCache(key) {
295
+ const entry = this.#cache.get(key);
296
+ if (entry && Date.now() - entry.time < this.#cacheTTL) return entry.value;
297
+ return null;
298
+ }
299
+
300
+ #setCache(key, value) {
301
+ this.#cache.set(key, { value, time: Date.now() });
302
+ }
303
+
304
+ clearCache() {
305
+ this.#cache.clear();
306
+ }
307
+ }