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,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,50 @@
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', 'Waiting for MCP handshake...');
24
+ await new Promise((resolve, reject) => {
25
+ const timeout = setTimeout(() => reject(new Error('MCP handshake timeout')), 30000);
26
+ const check = setInterval(() => {
27
+ if (this.#mqtt.mcp?.toolsReceived && this.#mqtt.sessionId) {
28
+ clearInterval(check);
29
+ clearTimeout(timeout);
30
+ debug('llm', 'MCP ready');
31
+ resolve();
32
+ }
33
+ }, 200);
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Send prompt to XiaoZhi (waits for MCP handshake first)
39
+ * Response comes back via llm:text or mcp:tool_call events
40
+ */
41
+ async sendPrompt(prompt) {
42
+ if (prompt.length > MAX_PROMPT_LEN) {
43
+ debug('llm', `Prompt too long: ${prompt.length} > ${MAX_PROMPT_LEN}`);
44
+ throw new Error(`Prompt too long (${prompt.length}/${MAX_PROMPT_LEN} chars)`);
45
+ }
46
+ await this.#waitForReady();
47
+ debug('llm', `Sending: ${prompt}`);
48
+ await this.#mqtt.sendText(prompt);
49
+ }
50
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * PlanManager — Task decomposition and execution tracking (OpenHands-style)
3
+ *
4
+ * Breaks complex tasks into steps, tracks progress, handles failures.
5
+ * The plan is a living document that evolves as the agent works.
6
+ */
7
+ import { bus } from '../core/event-bus.js';
8
+ import { debug } from '../core/logger.js';
9
+
10
+ export const StepStatus = {
11
+ PENDING: 'pending',
12
+ IN_PROGRESS: 'in_progress',
13
+ COMPLETED: 'completed',
14
+ FAILED: 'failed',
15
+ SKIPPED: 'skipped',
16
+ };
17
+
18
+ export class PlanStep {
19
+ constructor({ id, description, status = StepStatus.PENDING, result = null, error = null }) {
20
+ this.id = id;
21
+ this.description = description;
22
+ this.status = status;
23
+ this.result = result;
24
+ this.error = error;
25
+ this.startedAt = null;
26
+ this.completedAt = null;
27
+ }
28
+
29
+ start() {
30
+ this.status = StepStatus.IN_PROGRESS;
31
+ this.startedAt = Date.now();
32
+ }
33
+
34
+ complete(result) {
35
+ this.status = StepStatus.COMPLETED;
36
+ this.result = result;
37
+ this.completedAt = Date.now();
38
+ }
39
+
40
+ fail(error) {
41
+ this.status = StepStatus.FAILED;
42
+ this.error = error;
43
+ this.completedAt = Date.now();
44
+ }
45
+
46
+ skip(reason) {
47
+ this.status = StepStatus.SKIPPED;
48
+ this.result = reason;
49
+ this.completedAt = Date.now();
50
+ }
51
+
52
+ toJSON() {
53
+ return {
54
+ id: this.id,
55
+ description: this.description,
56
+ status: this.status,
57
+ result: this.result,
58
+ error: this.error,
59
+ duration: this.completedAt && this.startedAt ? this.completedAt - this.startedAt : null,
60
+ };
61
+ }
62
+ }
63
+
64
+ export class Plan {
65
+ constructor({ goal, steps = [] }) {
66
+ this.goal = goal;
67
+ this.steps = steps.map((s, i) => new PlanStep({ id: i + 1, ...s }));
68
+ this.createdAt = Date.now();
69
+ this.updatedAt = Date.now();
70
+ }
71
+
72
+ get currentStep() {
73
+ return this.steps.find(s => s.status === StepStatus.IN_PROGRESS);
74
+ }
75
+
76
+ get nextPending() {
77
+ return this.steps.find(s => s.status === StepStatus.PENDING);
78
+ }
79
+
80
+ get progress() {
81
+ const total = this.steps.length;
82
+ const completed = this.steps.filter(s => s.status === StepStatus.COMPLETED).length;
83
+ const failed = this.steps.filter(s => s.status === StepStatus.FAILED).length;
84
+ return { total, completed, failed, percent: total ? Math.round((completed / total) * 100) : 0 };
85
+ }
86
+
87
+ get isComplete() {
88
+ return this.steps.every(s =>
89
+ s.status === StepStatus.COMPLETED ||
90
+ s.status === StepStatus.FAILED ||
91
+ s.status === StepStatus.SKIPPED
92
+ );
93
+ }
94
+
95
+ get hasFailures() {
96
+ return this.steps.some(s => s.status === StepStatus.FAILED);
97
+ }
98
+
99
+ startStep(id) {
100
+ const step = this.steps.find(s => s.id === id);
101
+ if (step) {
102
+ step.start();
103
+ this.updatedAt = Date.now();
104
+ bus.emit('plan:step:start', { step: step.toJSON() });
105
+ }
106
+ return step;
107
+ }
108
+
109
+ completeStep(id, result) {
110
+ const step = this.steps.find(s => s.id === id);
111
+ if (step) {
112
+ step.complete(result);
113
+ this.updatedAt = Date.now();
114
+ bus.emit('plan:step:complete', { step: step.toJSON(), progress: this.progress });
115
+ }
116
+ return step;
117
+ }
118
+
119
+ failStep(id, error) {
120
+ const step = this.steps.find(s => s.id === id);
121
+ if (step) {
122
+ step.fail(error);
123
+ this.updatedAt = Date.now();
124
+ bus.emit('plan:step:fail', { step: step.toJSON(), progress: this.progress });
125
+ }
126
+ return step;
127
+ }
128
+
129
+ skipStep(id, reason) {
130
+ const step = this.steps.find(s => s.id === id);
131
+ if (step) {
132
+ step.skip(reason);
133
+ this.updatedAt = Date.now();
134
+ }
135
+ return step;
136
+ }
137
+
138
+ /**
139
+ * Add a new step (plan can be modified during execution)
140
+ */
141
+ addStep(description, afterId = null) {
142
+ const id = this.steps.length + 1;
143
+ const step = new PlanStep({ id, description });
144
+ if (afterId) {
145
+ const idx = this.steps.findIndex(s => s.id === afterId);
146
+ this.steps.splice(idx + 1, 0, step);
147
+ } else {
148
+ this.steps.push(step);
149
+ }
150
+ // Re-number steps
151
+ this.steps.forEach((s, i) => s.id = i + 1);
152
+ this.updatedAt = Date.now();
153
+ bus.emit('plan:updated', { plan: this.toJSON() });
154
+ return step;
155
+ }
156
+
157
+ /**
158
+ * Remove a pending step
159
+ */
160
+ removeStep(id) {
161
+ const idx = this.steps.findIndex(s => s.id === id && s.status === StepStatus.PENDING);
162
+ if (idx >= 0) {
163
+ this.steps.splice(idx, 1);
164
+ this.steps.forEach((s, i) => s.id = i + 1);
165
+ this.updatedAt = Date.now();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Format plan as prompt for LLM
171
+ */
172
+ toPrompt() {
173
+ const lines = [`Goal: ${this.goal}`, ''];
174
+ for (const step of this.steps) {
175
+ const icon = {
176
+ [StepStatus.PENDING]: '⬜',
177
+ [StepStatus.IN_PROGRESS]: '🔄',
178
+ [StepStatus.COMPLETED]: '✅',
179
+ [StepStatus.FAILED]: '❌',
180
+ [StepStatus.SKIPPED]: '⏭️',
181
+ }[step.status];
182
+
183
+ lines.push(`${icon} Step ${step.id}: ${step.description}`);
184
+ if (step.result && step.status === StepStatus.COMPLETED) {
185
+ lines.push(` Result: ${step.result}`);
186
+ }
187
+ if (step.error && step.status === StepStatus.FAILED) {
188
+ lines.push(` Error: ${step.error}`);
189
+ }
190
+ }
191
+ lines.push('');
192
+ lines.push(`Progress: ${this.progress.percent}% (${this.progress.completed}/${this.progress.total})`);
193
+ return lines.join('\n');
194
+ }
195
+
196
+ /**
197
+ * Format plan for display
198
+ */
199
+ toDisplay() {
200
+ const lines = [`📋 Plan: ${this.goal}`, '─'.repeat(40)];
201
+ for (const step of this.steps) {
202
+ const icon = {
203
+ [StepStatus.PENDING]: '⬜',
204
+ [StepStatus.IN_PROGRESS]: '🔄',
205
+ [StepStatus.COMPLETED]: '✅',
206
+ [StepStatus.FAILED]: '❌',
207
+ [StepStatus.SKIPPED]: '⏭️',
208
+ }[step.status];
209
+ lines.push(`${icon} ${step.id}. ${step.description}`);
210
+ }
211
+ lines.push('─'.repeat(40));
212
+ lines.push(`Progress: ${this.progress.percent}%`);
213
+ return lines.join('\n');
214
+ }
215
+
216
+ toJSON() {
217
+ return {
218
+ goal: this.goal,
219
+ steps: this.steps.map(s => s.toJSON()),
220
+ progress: this.progress,
221
+ createdAt: this.createdAt,
222
+ updatedAt: this.updatedAt,
223
+ };
224
+ }
225
+ }
226
+
227
+ export class PlanManager {
228
+ #currentPlan = null;
229
+ #history = [];
230
+
231
+ get currentPlan() { return this.#currentPlan; }
232
+ get history() { return [...this.#history]; }
233
+
234
+ /**
235
+ * Create a new plan from a goal
236
+ */
237
+ create(goal, steps = []) {
238
+ if (this.#currentPlan && !this.#currentPlan.isComplete) {
239
+ this.#history.push(this.#currentPlan);
240
+ }
241
+ this.#currentPlan = new Plan({ goal, steps });
242
+ bus.emit('plan:created', { plan: this.#currentPlan.toJSON() });
243
+ debug('plan', `Created plan: ${goal} (${steps.length} steps)`);
244
+ return this.#currentPlan;
245
+ }
246
+
247
+ /**
248
+ * Let the LLM generate a plan for a task
249
+ */
250
+ async generatePlan(task, llm, context) {
251
+ const prompt = `You are a task planner. Break this task into clear, actionable steps.
252
+
253
+ Task: ${task}
254
+
255
+ ${context ? `Context:\n${context}` : ''}
256
+
257
+ Respond with a JSON object:
258
+ {
259
+ "goal": "Brief description of the overall goal",
260
+ "steps": [
261
+ { "description": "Step 1 description" },
262
+ { "description": "Step 2 description" }
263
+ ]
264
+ }
265
+
266
+ Rules:
267
+ - Each step should be a single, clear action
268
+ - Steps should be in logical order
269
+ - Maximum 10 steps
270
+ - Be specific and actionable`;
271
+
272
+ try {
273
+ const response = await llm.chat([
274
+ { role: 'system', content: 'You are a task planning assistant. Respond only with valid JSON.' },
275
+ { role: 'user', content: prompt },
276
+ ]);
277
+
278
+ let content = response.content;
279
+ if (Array.isArray(content)) {
280
+ content = content.filter(c => c.type === 'text').map(c => c.text).join('');
281
+ }
282
+
283
+ // Parse JSON from response
284
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
285
+ if (jsonMatch) {
286
+ const planData = JSON.parse(jsonMatch[0]);
287
+ return this.create(planData.goal || task, planData.steps || []);
288
+ }
289
+ } catch (err) {
290
+ debug('plan', `Plan generation failed: ${err.message}`);
291
+ }
292
+
293
+ // Fallback: create simple plan
294
+ return this.create(task, [{ description: task }]);
295
+ }
296
+
297
+ /**
298
+ * Mark current step as complete and move to next
299
+ */
300
+ advanceCurrent(result) {
301
+ if (!this.#currentPlan) return null;
302
+ const current = this.#currentPlan.currentStep;
303
+ if (current) {
304
+ this.#currentPlan.completeStep(current.id, result);
305
+ }
306
+ const next = this.#currentPlan.nextPending;
307
+ if (next) {
308
+ this.#currentPlan.startStep(next.id);
309
+ }
310
+ return next;
311
+ }
312
+
313
+ /**
314
+ * Mark current step as failed
315
+ */
316
+ failCurrent(error) {
317
+ if (!this.#currentPlan) return null;
318
+ const current = this.#currentPlan.currentStep;
319
+ if (current) {
320
+ this.#currentPlan.failStep(current.id, error);
321
+ }
322
+ return current;
323
+ }
324
+
325
+ /**
326
+ * Get plan status for display
327
+ */
328
+ getStatus() {
329
+ if (!this.#currentPlan) return 'No active plan';
330
+ return this.#currentPlan.toDisplay();
331
+ }
332
+
333
+ /**
334
+ * Clear current plan
335
+ */
336
+ clear() {
337
+ if (this.#currentPlan) {
338
+ this.#history.push(this.#currentPlan);
339
+ this.#currentPlan = null;
340
+ }
341
+ }
342
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SystemPrompt — Minimal system prompt for XiaoZhi
3
+ *
4
+ * XiaoZhi doesn't support system prompts, so this is used
5
+ * only for agent-internal context tracking.
6
+ */
7
+
8
+ /**
9
+ * Build minimal system prompt (for internal use only, NOT sent to XiaoZhi)
10
+ */
11
+ export function buildSystemPrompt({ config, context, plan }) {
12
+ const name = config.agent?.name || 'Pelulu';
13
+ const parts = [`You are ${name}, a coding agent.`];
14
+
15
+ if (context) {
16
+ // Extract only git branch and project type
17
+ const branch = context.match(/Branch:\s*(\S+)/)?.[1];
18
+ const type = context.match(/Type:\s*(\S+)/)?.[1];
19
+ if (branch || type) {
20
+ parts.push([branch && `git:${branch}`, type].filter(Boolean).join(' | '));
21
+ }
22
+ }
23
+
24
+ if (plan) {
25
+ parts.push(`Plan: ${plan.goal} (${plan.progress?.percent || 0}%)`);
26
+ }
27
+
28
+ return parts.join(' ');
29
+ }
30
+
31
+ /**
32
+ * Match microagents based on user input
33
+ */
34
+ export function matchMicroagents(userInput, allMicroagents) {
35
+ if (!allMicroagents?.length) return [];
36
+ const input = userInput.toLowerCase();
37
+ return allMicroagents.filter(agent => {
38
+ if (!agent.trigger?.length) return true;
39
+ return agent.trigger.some(kw => input.includes(kw.toLowerCase()));
40
+ });
41
+ }