pelulu-cli 1.0.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * HistoryCondenser — Manage long conversations (OpenHands-style)
3
+ *
4
+ * When history gets too long, condense it by:
5
+ * 1. Summarizing old messages
6
+ * 2. Keeping recent messages intact
7
+ * 3. Preserving important context (files read, errors, decisions)
8
+ */
9
+ import { debug } from '../core/logger.js';
10
+
11
+ export class HistoryCondenser {
12
+ #maxMessages;
13
+ #maxTokens;
14
+ #condensedHistory = [];
15
+
16
+ constructor({ maxMessages = 50, maxTokens = 100000 } = {}) {
17
+ this.#maxMessages = maxMessages;
18
+ this.#maxTokens = maxTokens;
19
+ }
20
+
21
+ /**
22
+ * Check if history needs condensation
23
+ */
24
+ needsCondensation(messages) {
25
+ if (messages.length > this.#maxMessages) return true;
26
+ const estimatedTokens = this.#estimateTokens(messages);
27
+ if (estimatedTokens > this.#maxTokens) return true;
28
+ return false;
29
+ }
30
+
31
+ /**
32
+ * Condense history to fit within limits
33
+ * @param {Array} messages - Full message history
34
+ * @param {object} llm - LLM client for summarization (optional)
35
+ * @returns {Array} - Condensed messages
36
+ */
37
+ async condense(messages, llm = null) {
38
+ if (!this.needsCondensation(messages)) return messages;
39
+
40
+ debug('history', `Condensing ${messages.length} messages`);
41
+
42
+ // Strategy 1: Keep first message + last N messages
43
+ const keepRecent = Math.floor(this.#maxMessages * 0.7);
44
+ const firstMessage = messages[0];
45
+ const recentMessages = messages.slice(-keepRecent);
46
+
47
+ // Strategy 2: Summarize middle section if LLM available
48
+ if (llm) {
49
+ const middleMessages = messages.slice(1, -keepRecent);
50
+ const summary = await this.#summarize(middleMessages, llm);
51
+
52
+ if (summary) {
53
+ return [
54
+ firstMessage,
55
+ { role: 'system', content: `[Previous conversation summary]: ${summary}` },
56
+ ...recentMessages,
57
+ ];
58
+ }
59
+ }
60
+
61
+ // Fallback: Just keep first + recent
62
+ return [firstMessage, ...recentMessages];
63
+ }
64
+
65
+ /**
66
+ * Summarize a batch of messages using LLM
67
+ */
68
+ async #summarize(messages, llm) {
69
+ try {
70
+ const conversation = messages.map(m => {
71
+ if (m.role === 'user') return `User: ${m.content}`;
72
+ if (m.role === 'assistant') return `Assistant: ${m.content || '(tool call)'}`;
73
+ if (m.role === 'tool') return `Tool (${m.name}): ${typeof m.content === 'string' ? m.content.slice(0, 200) : '(result)'}`;
74
+ return '';
75
+ }).filter(Boolean).join('\n');
76
+
77
+ const response = await llm.chat([
78
+ { role: 'system', content: 'Summarize this conversation concisely. Focus on: files modified, decisions made, errors encountered, and current task status. Max 200 words.' },
79
+ { role: 'user', content: conversation },
80
+ ]);
81
+
82
+ let content = response.content;
83
+ if (Array.isArray(content)) {
84
+ content = content.filter(c => c.type === 'text').map(c => c.text).join('');
85
+ }
86
+
87
+ return content;
88
+ } catch (err) {
89
+ debug('history', `Summarization failed: ${err.message}`);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Extract important context from messages
96
+ * This context is preserved even when messages are condensed
97
+ */
98
+ extractImportantContext(messages) {
99
+ const context = {
100
+ filesRead: new Set(),
101
+ filesModified: new Set(),
102
+ errors: [],
103
+ decisions: [],
104
+ toolCalls: [],
105
+ };
106
+
107
+ for (const msg of messages) {
108
+ // Track file operations
109
+ if (msg.role === 'tool' && msg.name === 'file') {
110
+ try {
111
+ const result = JSON.parse(msg.content);
112
+ if (result.path) {
113
+ if (msg.args?.action === 'read') context.filesRead.add(result.path);
114
+ if (msg.args?.action === 'write' || msg.args?.action === 'edit') {
115
+ context.filesModified.add(result.path);
116
+ }
117
+ }
118
+ } catch {}
119
+ }
120
+
121
+ // Track errors
122
+ if (msg.role === 'tool' && msg.content?.includes('error')) {
123
+ context.errors.push(msg.content.slice(0, 200));
124
+ }
125
+
126
+ // Track tool calls
127
+ if (msg.role === 'tool') {
128
+ context.toolCalls.push({
129
+ name: msg.name,
130
+ timestamp: msg.timestamp,
131
+ });
132
+ }
133
+ }
134
+
135
+ return {
136
+ filesRead: [...context.filesRead],
137
+ filesModified: [...context.filesModified],
138
+ recentErrors: context.errors.slice(-5),
139
+ totalToolCalls: context.toolCalls.length,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Estimate token count (rough approximation)
145
+ */
146
+ #estimateTokens(messages) {
147
+ let totalChars = 0;
148
+ for (const msg of messages) {
149
+ if (typeof msg.content === 'string') {
150
+ totalChars += msg.content.length;
151
+ }
152
+ if (msg.tool_calls) {
153
+ totalChars += JSON.stringify(msg.tool_calls).length;
154
+ }
155
+ }
156
+ // Rough estimate: ~4 chars per token
157
+ return Math.ceil(totalChars / 4);
158
+ }
159
+
160
+ /**
161
+ * Build context summary for condensed history
162
+ */
163
+ buildContextSummary(messages) {
164
+ const context = this.extractImportantContext(messages);
165
+ const parts = [];
166
+
167
+ if (context.filesRead.length > 0) {
168
+ parts.push(`Files read: ${context.filesRead.join(', ')}`);
169
+ }
170
+ if (context.filesModified.length > 0) {
171
+ parts.push(`Files modified: ${context.filesModified.join(', ')}`);
172
+ }
173
+ if (context.recentErrors.length > 0) {
174
+ parts.push(`Recent errors:\n${context.recentErrors.map(e => ` - ${e}`).join('\n')}`);
175
+ }
176
+ parts.push(`Total tool calls: ${context.totalToolCalls}`);
177
+
178
+ return parts.join('\n');
179
+ }
180
+
181
+ /**
182
+ * Get optimal message window for a given token budget
183
+ */
184
+ getWindow(messages, tokenBudget) {
185
+ let currentTokens = 0;
186
+ let endIndex = messages.length;
187
+
188
+ // Walk backwards from the end
189
+ for (let i = messages.length - 1; i >= 0; i--) {
190
+ const msgTokens = this.#estimateTokens([messages[i]]);
191
+ if (currentTokens + msgTokens > tokenBudget) break;
192
+ currentTokens += msgTokens;
193
+ endIndex = i;
194
+ }
195
+
196
+ // Always include the first message (user's original request)
197
+ if (endIndex > 1) {
198
+ return [messages[0], ...messages.slice(endIndex)];
199
+ }
200
+ return messages.slice(endIndex);
201
+ }
202
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Agent Module — OpenHands-style agent system for Pelulu-CLI
3
+ */
4
+ export { AgentLoop, AgentState } from './agent-loop.js';
5
+ export { LLMClient } from './llm-client.js';
6
+ export { ContextBuilder } from './context-builder.js';
7
+ export { AgentController } from './agent-controller.js';
8
+ export { buildSystemPrompt, matchMicroagents } from './system-prompt.js';
@@ -0,0 +1,44 @@
1
+ /**
2
+ * LLMClient — XiaoZhi AI MQTT wrapper
3
+ *
4
+ * Sends user messages to XiaoZhi via MQTT.
5
+ * Response handling is done by AgentLoop via bus events.
6
+ */
7
+ import { debug } from '../core/logger.js';
8
+
9
+ const MAX_PROMPT_LEN = 70;
10
+
11
+ export class LLMClient {
12
+ #mqtt;
13
+
14
+ constructor(mqtt) {
15
+ this.#mqtt = mqtt;
16
+ }
17
+
18
+ /**
19
+ * Wait for MCP handshake to complete (tools received + session assigned)
20
+ */
21
+ async #waitForReady() {
22
+ if (this.#mqtt.mcp?.toolsReceived && this.#mqtt.sessionId) return;
23
+ debug('llm', 'Session not ready — re-establishing...');
24
+ // Actively re-send hello to recover from an idle `goodbye`, rather than
25
+ // polling for a session that will never come back on its own.
26
+ const ok = await this.#mqtt.ensureSession(30000);
27
+ if (!ok) throw new Error('MCP handshake timeout');
28
+ debug('llm', 'MCP ready');
29
+ }
30
+
31
+ /**
32
+ * Send prompt to XiaoZhi (waits for MCP handshake first)
33
+ * Response comes back via llm:text or mcp:tool_call events
34
+ */
35
+ async sendPrompt(prompt) {
36
+ if (prompt.length > MAX_PROMPT_LEN) {
37
+ debug('llm', `Prompt too long: ${prompt.length} > ${MAX_PROMPT_LEN}`);
38
+ throw new Error(`Prompt too long (${prompt.length}/${MAX_PROMPT_LEN} chars)`);
39
+ }
40
+ await this.#waitForReady();
41
+ debug('llm', `Sending: ${prompt}`);
42
+ await this.#mqtt.sendText(prompt);
43
+ }
44
+ }