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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,81 @@
1
+ # Changelog
2
+
3
+ All notable changes to **Pelulu CLI** will be documented in this file.
4
+
5
+ ## [1.1.0] - 2026-07-21
6
+
7
+ ### Fixed
8
+ - **MQTT disconnect loop** — `tools/list` response exceeded 8KB broker limit, now optimized to ~4KB
9
+ - **Agent sends prompt before session ready** — `LLMClient` now waits for MCP handshake + session before sending
10
+ - **Double tool execution** — agent loop no longer re-executes tools already handled by MQTT client
11
+ - **Prompt sent multiple times** — agent loop now sends prompt only once per run
12
+ - **AI responses not in log file** — added `llm:text` and `tts:sentence` event logging
13
+ - **Log files accumulate** — old logs deleted on startup, only latest kept
14
+ - **y/N confirmation prompt flashing** — agent tool calls auto-approved (no more UI glitches)
15
+ - **Version mismatch** — version now read from `package.json` (auto-sync)
16
+
17
+ ### Changed
18
+ - **Statusbar redesigned** — clean minimal layout: `🐱 PELULU ● online session:xxx xiaozhi.me`
19
+ - **Tool schema optimization** — `action` enum preserved, first 4 properties sent (under 8KB)
20
+ - **System prompt added to hello message** — tells XiaoZhi to use tools instead of just chatting
21
+
22
+ ## [1.0.5] - 2026-07-20
23
+
24
+ ### Added
25
+ - File logging (`logs/` directory)
26
+ - Char counter on TUI input (70 char limit)
27
+ - Scrollable chat (Shift+Up/Down, PgUp/PgDn)
28
+
29
+ ### Fixed
30
+ - Chat limited to 12 visible lines
31
+ - Progress feedback and timeout handling in agent loop
32
+
33
+ ## [1.0.4] - 2026-07-19
34
+
35
+ ### Changed
36
+ - Update checker uses npm registry instead of GitHub releases
37
+
38
+ ### Fixed
39
+ - Activation code displays directly to console
40
+
41
+ ## [1.0.3] - 2026-07-19
42
+
43
+ ### Fixed
44
+ - Duplicate user messages (Enter key fired onSubmit twice)
45
+ - TTS responses handled as assistant messages
46
+
47
+ ## [1.0.2] - 2026-07-18
48
+
49
+ ### Added
50
+ - Cute ASCII cat banner above CLI header
51
+ - Block CLI when update is available
52
+
53
+ ### Fixed
54
+ - Redirect ALL startup logs into Ink TUI
55
+ - Clean status bar header format
56
+
57
+ ## [1.0.1] - 2026-07-17
58
+
59
+ ### Added
60
+ - OpenHands-style agent system (AgentLoop, AgentController, LLMClient)
61
+ - Auto-completion with Tab key
62
+ - Auto-format for written files
63
+
64
+ ### Changed
65
+ - Replaced readline with Ink React TUI
66
+
67
+ ### Fixed
68
+ - Compact banner text
69
+ - TTY fallback for non-interactive mode
70
+ - Log status as single-line auto-updating text
71
+
72
+ ## [1.0.0] - 2026-07-16
73
+
74
+ ### Added
75
+ - Initial release
76
+ - XiaoZhi AI integration via MQTT
77
+ - MCP tool protocol support
78
+ - 18 built-in tools (file, shell, git, project, network, etc.)
79
+ - Interactive REPL with syntax highlighting
80
+ - Plugin system
81
+ - Workspace detection and context building
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "pelulu-cli",
3
- "version": "1.0.5",
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",
7
7
  "bin": {
8
- "pelulu": "src/index.js"
8
+ "pelulu-cli": "src/index.js"
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node src/index.js",
12
12
  "start:debug": "node src/index.js --debug",
13
13
  "start:fallback": "node src/index.js --no-ink",
14
+ "start:no-agent": "node src/index.js --no-agent",
14
15
  "tools": "node src/index.js --list-tools",
15
16
  "test": "node src/index.js --test"
16
17
  },
@@ -26,13 +27,13 @@
26
27
  ],
27
28
  "repository": {
28
29
  "type": "git",
29
- "url": "git+https://github.com/chaerulchas/Pelulu-CLI.git"
30
+ "url": "git+https://github.com/venenapro/Pelulu-CLI.git"
30
31
  },
31
32
  "bugs": {
32
- "url": "https://github.com/chaerulchas/Pelulu-CLI/issues"
33
+ "url": "https://github.com/venenapro/Pelulu-CLI/issues"
33
34
  },
34
- "homepage": "https://github.com/chaerulchas/Pelulu-CLI#readme",
35
- "author": "chaerulchas",
35
+ "homepage": "https://github.com/venenapro/Pelulu-CLI#readme",
36
+ "author": "venenapro",
36
37
  "license": "MIT",
37
38
  "dependencies": {
38
39
  "chalk": "^5.3.0",
@@ -43,5 +44,8 @@
43
44
  },
44
45
  "engines": {
45
46
  "node": ">=18"
47
+ },
48
+ "devDependencies": {
49
+ "ink-testing-library": "^4.0.0"
46
50
  }
47
51
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * AgentController — Orchestrates the agent system
3
+ */
4
+ import { AgentLoop } from './agent-loop.js';
5
+ import { LLMClient } from './llm-client.js';
6
+ import { ContextBuilder } from './context-builder.js';
7
+ import { bus } from '../core/event-bus.js';
8
+ import { debug } from '../core/logger.js';
9
+
10
+ export class AgentController {
11
+ #loop;
12
+ #llm;
13
+ #contextBuilder;
14
+ #registry;
15
+ #sandbox;
16
+ #confirm;
17
+ #config;
18
+ #running = false;
19
+
20
+ constructor({ registry, mqtt, sandbox, confirm, config }) {
21
+ this.#registry = registry;
22
+ this.#sandbox = sandbox;
23
+ this.#confirm = confirm;
24
+ this.#config = config;
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
+ });
31
+ this.#llm = new LLMClient(mqtt);
32
+ this.#contextBuilder = new ContextBuilder();
33
+ }
34
+
35
+ get isRunning() { return this.#running; }
36
+
37
+ get summary() {
38
+ return {
39
+ state: this.#loop.state,
40
+ iteration: this.#loop.iteration,
41
+ history: this.#loop.history.length,
42
+ running: this.#running,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Run agent for a user prompt
48
+ */
49
+ async run(userPrompt, options = {}) {
50
+ // Validate input length
51
+ const MAX_LEN = 70;
52
+ if (userPrompt.length > MAX_LEN) {
53
+ throw new Error(`Input too long (${userPrompt.length}/${MAX_LEN} chars)`);
54
+ }
55
+
56
+ if (this.#running) {
57
+ this.abort();
58
+ await new Promise(r => setTimeout(r, 300));
59
+ }
60
+
61
+ this.#running = true;
62
+ const start = Date.now();
63
+
64
+ try {
65
+ debug('agent', `Running: ${userPrompt}`);
66
+
67
+ const result = await this.#loop.run(userPrompt, {
68
+ llm: this.#llm,
69
+ tools: this.#registry,
70
+ sandbox: this.#sandbox,
71
+ confirm: this.#confirm,
72
+ });
73
+
74
+ result.duration = Date.now() - start;
75
+ debug('agent', `Done in ${result.duration}ms: ${result.success}`);
76
+ return result;
77
+
78
+ } finally {
79
+ this.#running = false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Abort current run
85
+ */
86
+ abort() {
87
+ this.#loop.abort();
88
+ this.#running = false;
89
+ debug('agent', 'Aborted');
90
+ }
91
+
92
+ /**
93
+ * Get workspace context
94
+ */
95
+ async getContext() {
96
+ return this.#contextBuilder.build();
97
+ }
98
+
99
+ /**
100
+ * Reset agent state
101
+ */
102
+ reset() {
103
+ this.#running = false;
104
+ }
105
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * AgentLoop — Core observe→think→act cycle
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.
15
+ */
16
+ import { bus } from '../core/event-bus.js';
17
+ import { debug } from '../core/logger.js';
18
+
19
+ export const AgentState = {
20
+ IDLE: 'idle',
21
+ THINKING: 'thinking',
22
+ ACTING: 'acting',
23
+ FINISHED: 'finished',
24
+ ERROR: 'error',
25
+ };
26
+
27
+ export class AgentLoop {
28
+ #state = AgentState.IDLE;
29
+ #iteration = 0;
30
+ #maxIterations;
31
+ #abortController = null;
32
+ #history = [];
33
+ #idleTimeoutMs;
34
+ #quietMs;
35
+ #postToolGraceMs;
36
+
37
+ constructor({ maxIterations = 50, idleTimeoutMs = 45000, quietMs = 2500, postToolGraceMs = 9000 } = {}) {
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;
48
+ }
49
+
50
+ get state() { return this.#state; }
51
+ get iteration() { return this.#iteration; }
52
+ get history() { return [...this.#history]; }
53
+
54
+ #setState(s) {
55
+ const old = this.#state;
56
+ this.#state = s;
57
+ debug('agent', `${old} → ${s}`);
58
+ bus.emit('agent:state', { from: old, to: s });
59
+ }
60
+
61
+ /**
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.
73
+ */
74
+ async run(userPrompt, { llm } = {}) {
75
+ this.#iteration = 0;
76
+ this.#abortController = new AbortController();
77
+ this.#history = [{ role: 'user', content: userPrompt, ts: Date.now() }];
78
+
79
+ this.#setState(AgentState.THINKING);
80
+ bus.emit('agent:progress', { state: 'thinking', message: 'Thinking...' });
81
+
82
+ try {
83
+ const outcome = await this.#observeTurn(llm, userPrompt);
84
+ this.#setState(AgentState.FINISHED);
85
+ return outcome;
86
+ } catch (err) {
87
+ if (err.message === 'AbortError') {
88
+ this.#setState(AgentState.FINISHED);
89
+ return { success: true, result: 'Aborted', iterations: this.#iteration };
90
+ }
91
+ this.#setState(AgentState.ERROR);
92
+ bus.emit('agent:error', { error: err.message, iteration: this.#iteration });
93
+ return { success: false, result: `Error: ${err.message}`, iterations: this.#iteration };
94
+ }
95
+ }
96
+
97
+ abort() {
98
+ this.#abortController?.abort();
99
+ }
100
+
101
+ /**
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.
114
+ */
115
+ #observeTurn(llm, userPrompt) {
116
+ return new Promise((resolve, reject) => {
117
+ let settled = false;
118
+ let buffer = '';
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
+ };
129
+ const cleanup = () => {
130
+ clearTimers();
131
+ bus.off('llm:text', onText);
132
+ bus.off('tts:sentence', onText);
133
+ bus.off('mcp:tool_call', onToolCall);
134
+ bus.off('mcp:tool_result', onToolResult);
135
+ };
136
+
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
+ };
144
+
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 });
151
+ };
152
+
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 });
158
+ };
159
+
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;
168
+ cleanup();
169
+ reject(new Error('Timeout: XiaoZhi not responding'));
170
+ }, this.#idleTimeoutMs);
171
+ };
172
+
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
+ };
182
+
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
+ };
203
+
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);
220
+ }
221
+ };
222
+
223
+ this.#abortController.signal.addEventListener('abort', () => {
224
+ if (settled) return;
225
+ settled = true;
226
+ cleanup();
227
+ reject(new Error('AbortError'));
228
+ }, { once: true });
229
+
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
+ });
246
+ });
247
+ }
248
+ }