pelulu-cli 1.1.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelulu-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI coding agent powered by XiaoZhi AI — a tiny Chinese LLM that lives in ESP32 chips",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -27,13 +27,13 @@
27
27
  ],
28
28
  "repository": {
29
29
  "type": "git",
30
- "url": "git+https://github.com/chaerulchas/Pelulu-CLI.git"
30
+ "url": "git+https://github.com/venenapro/Pelulu-CLI.git"
31
31
  },
32
32
  "bugs": {
33
- "url": "https://github.com/chaerulchas/Pelulu-CLI/issues"
33
+ "url": "https://github.com/venenapro/Pelulu-CLI/issues"
34
34
  },
35
- "homepage": "https://github.com/chaerulchas/Pelulu-CLI#readme",
36
- "author": "chaerulchas",
35
+ "homepage": "https://github.com/venenapro/Pelulu-CLI#readme",
36
+ "author": "venenapro",
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
39
  "chalk": "^5.3.0",
@@ -44,5 +44,8 @@
44
44
  },
45
45
  "engines": {
46
46
  "node": ">=18"
47
+ },
48
+ "devDependencies": {
49
+ "ink-testing-library": "^4.0.0"
47
50
  }
48
51
  }
@@ -22,7 +22,12 @@ export class AgentController {
22
22
  this.#sandbox = sandbox;
23
23
  this.#confirm = confirm;
24
24
  this.#config = config;
25
- this.#loop = new AgentLoop({ maxIterations: config.agent?.max_iterations || 50 });
25
+ this.#loop = new AgentLoop({
26
+ maxIterations: config.agent?.max_iterations || 50,
27
+ idleTimeoutMs: config.agent?.response_idle_ms || 45000,
28
+ quietMs: config.agent?.reply_quiet_ms || 2500,
29
+ postToolGraceMs: config.agent?.post_tool_grace_ms || 9000,
30
+ });
26
31
  this.#llm = new LLMClient(mqtt);
27
32
  this.#contextBuilder = new ContextBuilder();
28
33
  }
@@ -1,9 +1,17 @@
1
1
  /**
2
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)
3
+ *
4
+ * Handles the THREE ways XiaoZhi actually talks back:
5
+ * - Spoken replies via `tts:sentence` ← this is the PRIMARY reply channel
6
+ * - Plain text replies via `llm:text` ← rarely used, but supported
7
+ * - MCP tool calls via `mcp:tool_call`
8
+ *
9
+ * Turn completion is activity-aware: the loop treats text/speech/tool events as
10
+ * liveness and only considers the turn finished after a short quiet period, so
11
+ * multi-step builds (write several files, then a spoken "done") no longer look
12
+ * like a timeout. Previously the loop ignored `tts:sentence` entirely, so after
13
+ * a tool ran it waited out the full timeout and reported "XiaoZhi not
14
+ * responding" even though XiaoZhi had already answered by voice.
7
15
  */
8
16
  import { bus } from '../core/event-bus.js';
9
17
  import { debug } from '../core/logger.js';
@@ -22,9 +30,21 @@ export class AgentLoop {
22
30
  #maxIterations;
23
31
  #abortController = null;
24
32
  #history = [];
33
+ #idleTimeoutMs;
34
+ #quietMs;
35
+ #postToolGraceMs;
25
36
 
26
- constructor({ maxIterations = 50 } = {}) {
37
+ constructor({ maxIterations = 50, idleTimeoutMs = 45000, quietMs = 2500, postToolGraceMs = 9000 } = {}) {
27
38
  this.#maxIterations = maxIterations;
39
+ // Hard cap: reject only if NOTHING at all (text, speech, tool) arrives for
40
+ // this long. Reset on every event, so long multi-tool turns stay alive.
41
+ this.#idleTimeoutMs = idleTimeoutMs;
42
+ // A spoken/text reply is considered complete after this much silence.
43
+ this.#quietMs = quietMs;
44
+ // After the last tool result, how long to wait for XiaoZhi to either run
45
+ // another tool or speak before concluding the turn as "tools done, no
46
+ // verbal reply". Keeps a silent voice model from hanging the whole turn.
47
+ this.#postToolGraceMs = postToolGraceMs;
28
48
  }
29
49
 
30
50
  get state() { return this.#state; }
@@ -39,106 +59,35 @@ export class AgentLoop {
39
59
  }
40
60
 
41
61
  /**
42
- * Run agent loop for a user prompt
62
+ * Run one user turn.
63
+ *
64
+ * IMPORTANT — architecture: XiaoZhi is the MCP *client* and drives its own
65
+ * server-side think→act loop. Our CLI is the MCP *server*: `mqtt-client.js`
66
+ * already receives each `tools/call`, executes it, and returns the result to
67
+ * XiaoZhi automatically. So this loop must NOT drive tool execution or wait
68
+ * for tools in lock-step. It is a single, persistent OBSERVER of the whole
69
+ * turn's event stream. Oscillating between "wait for text" and "wait for tool
70
+ * result" phases (the old design) meant the final `tts:sentence` reply landed
71
+ * in a gap where nothing was listening — which is exactly why the turn hung
72
+ * and reported "XiaoZhi not responding" after files were read.
43
73
  */
44
- async run(userPrompt, { llm, tools, sandbox, confirm }) {
74
+ async run(userPrompt, { llm } = {}) {
45
75
  this.#iteration = 0;
46
76
  this.#abortController = new AbortController();
47
77
  this.#history = [{ role: 'user', content: userPrompt, ts: Date.now() }];
48
- this._promptSent = false;
49
78
 
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
- }
79
+ this.#setState(AgentState.THINKING);
80
+ bus.emit('agent:progress', { state: 'thinking', message: 'Thinking...' });
137
81
 
82
+ try {
83
+ const outcome = await this.#observeTurn(llm, userPrompt);
138
84
  this.#setState(AgentState.FINISHED);
139
- return { success: false, result: `Max iterations (${this.#maxIterations}) reached`, iterations: this.#iteration };
140
-
85
+ return outcome;
141
86
  } catch (err) {
87
+ if (err.message === 'AbortError') {
88
+ this.#setState(AgentState.FINISHED);
89
+ return { success: true, result: 'Aborted', iterations: this.#iteration };
90
+ }
142
91
  this.#setState(AgentState.ERROR);
143
92
  bus.emit('agent:error', { error: err.message, iteration: this.#iteration });
144
93
  return { success: false, result: `Error: ${err.message}`, iterations: this.#iteration };
@@ -150,127 +99,150 @@ export class AgentLoop {
150
99
  }
151
100
 
152
101
  /**
153
- * Send prompt to XiaoZhi only once per run
102
+ * Observe a full turn with ONE set of listeners that stay attached from the
103
+ * moment the prompt is sent until the turn settles. Nothing is ever missed
104
+ * between tool calls.
105
+ *
106
+ * Completion rules (whichever fires first):
107
+ * - Spoken/text reply settles: buffer got text, then went quiet for
108
+ * #quietMs, and no tool call is in flight → success with the reply.
109
+ * - Tools finished silently: XiaoZhi ran tools and produced no verbal
110
+ * reply within #postToolGraceMs of the last result → success, reported
111
+ * as the tool activity (prevents the long hang the user was seeing).
112
+ * - Hard idle: absolutely nothing (text, speech, tool call, tool result)
113
+ * for #idleTimeoutMs → treated as a stalled turn.
154
114
  */
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() {
115
+ #observeTurn(llm, userPrompt) {
167
116
  return new Promise((resolve, reject) => {
168
- let resolved = false;
117
+ let settled = false;
169
118
  let buffer = '';
170
- let timer = null;
171
- let gotToolCall = false;
172
-
119
+ let pendingTools = 0; // tool calls started but not yet resulted
120
+ let toolsRun = 0; // total tool calls seen this turn
121
+ let lastTool = null; // name of the most recent tool
122
+ let quietTimer = null; // fires when a verbal reply goes quiet
123
+ let graceTimer = null; // fires when tools finish with no verbal reply
124
+ let idleTimer = null; // hard "nothing at all is happening" cap
125
+
126
+ const clearTimers = () => {
127
+ clearTimeout(quietTimer); clearTimeout(graceTimer); clearTimeout(idleTimer);
128
+ };
173
129
  const cleanup = () => {
174
- if (timer) clearTimeout(timer);
130
+ clearTimers();
175
131
  bus.off('llm:text', onText);
176
- bus.off('mcp:tool_call', onTool);
132
+ bus.off('tts:sentence', onText);
133
+ bus.off('mcp:tool_call', onToolCall);
134
+ bus.off('mcp:tool_result', onToolResult);
177
135
  };
178
136
 
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
- });
137
+ const finish = (outcome) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ cleanup();
141
+ bus.emit('agent:progress', { state: 'done', message: 'Done!' });
142
+ resolve(outcome);
143
+ };
187
144
 
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);
145
+ const finishWithText = () => {
146
+ if (pendingTools > 0) return; // a tool is still running; not done yet
147
+ const content = buffer.trim();
148
+ if (!content) return;
149
+ this.#history.push({ role: 'assistant', content, ts: Date.now() });
150
+ finish({ success: true, result: content, iterations: this.#iteration });
196
151
  };
197
152
 
198
- const onTool = (data) => {
199
- if (resolved) return;
200
- gotToolCall = true;
201
- resolved = true;
202
- cleanup();
203
- resolve({ type: 'tool_call', ...data });
153
+ const finishSilentTools = () => {
154
+ if (settled || pendingTools > 0 || buffer.trim()) return;
155
+ const summary = `Completed ${toolsRun} tool ${toolsRun === 1 ? 'action' : 'actions'}${lastTool ? ` (last: ${lastTool})` : ''}.`;
156
+ this.#history.push({ role: 'assistant', content: summary, ts: Date.now() });
157
+ finish({ success: true, result: summary, iterations: this.#iteration });
204
158
  };
205
159
 
206
- // Timeout after 60s
207
- const timeout = setTimeout(() => {
208
- if (!resolved) {
209
- resolved = true;
160
+ // Reset the hard idle cap on every sign of life.
161
+ const armIdle = () => {
162
+ clearTimeout(idleTimer);
163
+ idleTimer = setTimeout(() => {
164
+ if (settled) return;
165
+ if (buffer.trim()) { finishWithText(); return; }
166
+ if (toolsRun > 0) { finishSilentTools(); return; }
167
+ settled = true;
210
168
  cleanup();
211
169
  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 });
170
+ }, this.#idleTimeoutMs);
171
+ };
224
172
 
225
- bus.on('llm:text', onText);
226
- bus.on('mcp:tool_call', onTool);
227
- });
228
- }
173
+ const onText = (text) => {
174
+ if (settled || typeof text !== 'string') return;
175
+ buffer += (buffer ? ' ' : '') + text;
176
+ clearTimeout(graceTimer); // a verbal reply supersedes the silent-tool path
177
+ armIdle();
178
+ bus.emit('agent:progress', { state: 'receiving', message: `Receiving ${buffer.length} chars...` });
179
+ clearTimeout(quietTimer);
180
+ quietTimer = setTimeout(finishWithText, this.#quietMs);
181
+ };
229
182
 
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;
183
+ const onToolCall = ({ name, args }) => {
184
+ if (settled) return;
185
+ pendingTools++;
186
+ toolsRun++;
187
+ lastTool = name;
188
+ this.#iteration++;
189
+ this.#setState(AgentState.ACTING);
190
+ // Text spoken BEFORE a tool call is just "thinking out loud" filler
191
+ // (e.g. "% file...") — the real answer is what XiaoZhi says after the
192
+ // tools finish. Drop the filler so the final result is clean.
193
+ buffer = '';
194
+ clearTimeout(quietTimer); // don't finish text while a tool is running
195
+ clearTimeout(graceTimer);
196
+ armIdle();
197
+ bus.emit('agent:progress', {
198
+ state: 'tool', tool: name, action: args?.action,
199
+ iteration: this.#iteration,
200
+ message: `Running ${name}${args?.action ? '.' + args.action : ''}...`,
201
+ });
202
+ };
237
203
 
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);
204
+ const onToolResult = ({ name, result }) => {
205
+ if (settled) return;
206
+ if (pendingTools > 0) pendingTools--;
207
+ this.#history.push({ role: 'tool', name, content: JSON.stringify(result), ts: Date.now() });
208
+ this.#setState(AgentState.THINKING);
209
+ armIdle();
210
+ bus.emit('agent:progress', {
211
+ state: 'tool_done', tool: name,
212
+ message: `${name} done${result?.isError ? ' (error)' : ''}, waiting for next step...`,
213
+ });
214
+ if (pendingTools === 0) {
215
+ // All in-flight tools are done. Give XiaoZhi a grace window to either
216
+ // fire another tool call or speak a final reply. If it stays silent,
217
+ // conclude the turn instead of hanging until the idle timeout.
218
+ clearTimeout(graceTimer);
219
+ graceTimer = setTimeout(finishSilentTools, this.#postToolGraceMs);
246
220
  }
247
221
  };
248
222
 
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);
223
+ this.#abortController.signal.addEventListener('abort', () => {
224
+ if (settled) return;
225
+ settled = true;
226
+ cleanup();
227
+ reject(new Error('AbortError'));
228
+ }, { once: true });
262
229
 
263
- bus.on('mcp:tool_result', onResult);
230
+ bus.on('llm:text', onText);
231
+ bus.on('tts:sentence', onText);
232
+ bus.on('mcp:tool_call', onToolCall);
233
+ bus.on('mcp:tool_result', onToolResult);
234
+
235
+ armIdle();
236
+
237
+ // Send the prompt exactly once, after listeners are attached so we never
238
+ // miss an immediate reply.
239
+ debug('agent', `Sending prompt: ${userPrompt}`);
240
+ Promise.resolve(llm?.sendPrompt(userPrompt)).catch((err) => {
241
+ if (settled) return;
242
+ settled = true;
243
+ cleanup();
244
+ reject(err instanceof Error ? err : new Error(String(err)));
245
+ });
264
246
  });
265
247
  }
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
248
  }
@@ -20,18 +20,12 @@ export class LLMClient {
20
20
  */
21
21
  async #waitForReady() {
22
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
- });
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');
35
29
  }
36
30
 
37
31
  /**
@@ -30,7 +30,7 @@ let _config = null;
30
30
  export async function loadConfig(root) {
31
31
  const path = join(root, CONFIG_FILE);
32
32
  const defaults = {
33
- agent: { name: 'Pelulu CLI', version: '1.0.0', workspace: '~/Pelulu-CLI' },
33
+ agent: { name: 'Pelulu CLI', version: '1.1.0', workspace: '~/Pelulu-CLI' },
34
34
  mqtt: { ota_url: 'https://api.tenclass.net/xiaozhi/ota/', keepalive: 240 },
35
35
  mcp: { endpoint_url: '' },
36
36
  tools: { shell_timeout: 30000, max_output: 10000, auto_format: true },