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.
- package/CHANGELOG.md +81 -0
- package/docs/AGENT.md +237 -0
- package/package.json +3 -2
- package/specifications/xiaozhi-mqtt-broker.md +46 -0
- package/src/agent/agent-controller.js +100 -0
- package/src/agent/agent-loop.js +276 -0
- package/src/agent/context-builder.js +307 -0
- package/src/agent/history-condenser.js +202 -0
- package/src/agent/index.js +8 -0
- package/src/agent/llm-client.js +50 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- package/src/core/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +13 -5
- package/src/index.js +82 -22
- package/src/mcp/mcp-handler.js +29 -2
- package/src/mcp/mqtt-client.js +15 -3
- package/src/tools/agent.js +80 -0
- package/src/tui/completable-input.js +49 -42
- package/src/tui/ink-app.js +91 -12
- package/src/tui/ink-components.js +58 -11
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
|
@@ -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
|
+
}
|