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/src/index.js CHANGED
@@ -2,18 +2,26 @@
2
2
  /**
3
3
  * Pelulu CLI — Entry Point
4
4
  * Ink-based TUI with React components
5
+ *
6
+ * Now with OpenHands-style agent capabilities:
7
+ * - Agent Loop (observe→think→act)
8
+ * - Plan Management
9
+ * - Enhanced Context
10
+ * - History Condensation
5
11
  */
6
12
  import { dirname, join } from 'path';
13
+ import { readFile } from 'fs/promises';
7
14
  import { fileURLToPath } from 'url';
8
15
  import chalk from 'chalk';
9
- import { loadConfig, saveConfig } from './core/config.js';
10
- import { log, setDebug } from './core/logger.js';
16
+ import { existsSync } from 'fs';
17
+ import { loadConfig, saveConfig, expand } from './core/config.js';
18
+ import { log, setDebug, debug, setInkMode, initFileLog, flushLogs, getLogFile, writeRawToLog } from './core/logger.js';
11
19
  import { bus } from './core/event-bus.js';
12
20
  import { ToolRegistry } from './core/tool-registry.js';
21
+ import { jobManager } from './core/job-manager.js';
13
22
  import { Sandbox } from './core/sandbox.js';
14
23
  import { SessionState } from './core/session.js';
15
24
  import { Stats } from './core/stats.js';
16
- import { buildSystemPrompt } from './core/system-prompt.js';
17
25
  import { buildContext } from './core/context.js';
18
26
  import { isDestructive, askConfirmation } from './core/confirm.js';
19
27
  import { runWizard } from './core/wizard.js';
@@ -28,15 +36,19 @@ import { MessageSender } from './mcp/message-sender.js';
28
36
  import { WssEndpoint } from './mcp/wss-endpoint.js';
29
37
  import { PluginManager } from './plugins/manager.js';
30
38
  import { checkForUpdates } from './core/update-checker.js';
31
- import { renderUpdateNotification, renderAsciiBanner } from './tui/renderer.js';
39
+ import { renderUpdateNotification, renderPostUpdate, renderAsciiBanner } from './tui/renderer.js';
32
40
  import { startInkTUI } from './tui/ink-entry.js';
33
41
 
42
+ // Import new agent system
43
+ import { AgentController } from './agent/agent-controller.js';
44
+
34
45
  const __dirname = dirname(fileURLToPath(import.meta.url));
35
46
  const ROOT = join(__dirname, '..');
36
47
 
37
48
  const args = process.argv.slice(2);
38
49
  if (args.includes('--debug')) setDebug(true);
39
50
  const LIST_TOOLS = args.includes('--list-tools');
51
+ const NO_AGENT = args.includes('--no-agent');
40
52
 
41
53
  async function main() {
42
54
  // Buffer ALL startup logs for Ink to display inside TUI
@@ -47,11 +59,32 @@ async function main() {
47
59
  // 1. Load config
48
60
  const config = await loadConfig(ROOT);
49
61
 
50
- // 2. Check for updates block if outdated
62
+ // 1b. Initialize file logging (deletes old logs, keeps only latest)
63
+ const appName = config.agent?.name?.toLowerCase().replace(/\s+/g, '-') || 'pelulu';
64
+ const logFile = await initFileLog(ROOT, appName);
65
+ debug('init', `Log file: ${logFile}`);
66
+
67
+ // 1c. Enable Ink mode early — route ALL logs through bus (no console.log leak)
68
+ if (process.stdin.isTTY) {
69
+ setInkMode(true, bus);
70
+ }
71
+
72
+ // 2. Auto-update — silently install latest if outdated
51
73
  const update = await checkForUpdates(ROOT);
52
74
  if (update.available) {
53
- renderUpdateNotification(update);
54
- process.exit(1);
75
+ const pkgName = await (async () => {
76
+ try { return JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf-8')).name; } catch { return 'pelulu-cli'; }
77
+ })();
78
+ try {
79
+ const { execSync } = await import('child_process');
80
+ execSync(`npm install -g ${pkgName}@latest`, { stdio: 'ignore' });
81
+ renderPostUpdate(pkgName, update.remote);
82
+ process.exit(0);
83
+ } catch (e) {
84
+ console.error(chalk.red(` ✗ Update failed: ${e.message}`));
85
+ console.log(chalk.gray(` Run manually: npm install -g ${pkgName}@latest`));
86
+ process.exit(1);
87
+ }
55
88
  }
56
89
 
57
90
  // 3. Workspace & wizard
@@ -61,8 +94,11 @@ async function main() {
61
94
  await runWizard(ROOT);
62
95
  }
63
96
 
64
- // Show ASCII banner before Ink takes over
65
- renderAsciiBanner(config.agent?.version);
97
+ // Banner is now a React component inside Ink TUI (AsciiBanner)
98
+ // Only render console banner for fallback REPL (non-TTY)
99
+ if (!process.stdin.isTTY) {
100
+ await renderAsciiBanner();
101
+ }
66
102
 
67
103
  // Redirect console.log to buffer — everything after this goes into Ink
68
104
  console.log = bufferLog;
@@ -95,7 +131,6 @@ async function main() {
95
131
 
96
132
  // 6. Build context & system prompt
97
133
  const context = await buildContext();
98
- const systemPrompt = buildSystemPrompt(registry, config);
99
134
 
100
135
  // 7. Connect to XiaoZhi
101
136
  const mqtt = new MqttClient(config);
@@ -125,30 +160,103 @@ async function main() {
125
160
  // 8. Message sender
126
161
  const sender = new MessageSender(mqtt);
127
162
 
128
- // 9. Register MCP tool handler
163
+ // 9. Initialize Agent Controller (OpenHands-style)
164
+ let agentController = null;
165
+ if (!NO_AGENT) {
166
+ agentController = new AgentController({
167
+ registry,
168
+ mqtt,
169
+ sandbox,
170
+ confirm: { isDestructive, ask: askConfirmation },
171
+ config,
172
+ });
173
+
174
+ startupLogs.push(chalk.green(' ✓ Agent system initialized'));
175
+ startupLogs.push(chalk.gray(` - Max iterations: ${config.agent?.max_iterations || 50}`));
176
+ startupLogs.push(chalk.gray(` - Max input: 70 chars`));
177
+ startupLogs.push(chalk.gray(` - Log: ${getLogFile()}`));
178
+ }
179
+
180
+ // 10. Register MCP tool handler (for XiaoZhi direct tool calls)
181
+ // Agent calls are auto-approved — XiaoZhi controls the flow, no y/N prompts
182
+ // File tool actions that mutate the filesystem — tracked + surfaced in chat
183
+ const FILE_MUTATIONS = ['write', 'edit', 'delete', 'mkdir', 'copy', 'move'];
184
+
129
185
  mqtt.registerToolHandler(
130
186
  async (name, args) => {
131
- const destructive = isDestructive(name, args);
132
- if (destructive.destructive) {
133
- const ok = await askConfirmation(name, args, destructive);
134
- if (!ok) return { isError: true, content: [{ type: 'text', text: 'Cancelled by user' }] };
135
- }
136
187
  sandbox.validate(name, args);
137
188
 
138
189
  thinking.set('tool_call');
139
190
  const start = Date.now();
140
- try {
141
- const result = await registry.call(name, args);
191
+
192
+ // Capture pre-state so we can report created vs modified accurately
193
+ const trackedPath = args?.to || args?.path;
194
+ let preExisted = false;
195
+ if (name === 'file' && FILE_MUTATIONS.includes(args?.action) && trackedPath) {
196
+ try { preExisted = existsSync(expand(trackedPath)); } catch {}
197
+ }
198
+
199
+ // Post-processing shared by the inline and background completion paths:
200
+ // record stats, track file mutations, and surface the result in the TUI.
201
+ const finalize = (result) => {
142
202
  stats.record(name, args.action, !result.isError, Date.now() - start);
143
203
  session.addToolCall(name, args, result);
144
204
 
145
- if (name === 'file' && (args.action === 'write' || args.action === 'edit') && args.path) {
146
- autoFormat(args.path).catch(() => {});
205
+ // Track file changes and broadcast them so the TUI can show tracking in chat
206
+ if (name === 'file' && !result.isError && FILE_MUTATIONS.includes(args.action) && trackedPath) {
207
+ const change =
208
+ args.action === 'delete' ? 'deleted'
209
+ : (args.action === 'edit' || preExisted) ? 'modified'
210
+ : 'created';
211
+ fileTracker.track(trackedPath, change);
212
+ bus.emit('files:changed', { path: trackedPath, change, changes: fileTracker.getChanges() });
213
+
214
+ if (args.action === 'write' || args.action === 'edit') {
215
+ autoFormat(trackedPath).catch(() => {});
216
+ }
147
217
  }
148
218
 
149
- thinking.set('idle');
150
219
  bus.emit('tool:called', { name, result, args });
151
- return result;
220
+ };
221
+
222
+ try {
223
+ // Route EVERY tool action through the job layer. Fast actions resolve
224
+ // inline (unchanged UX); slow ones background themselves and return a
225
+ // pollable job handle so XiaoZhi never assumes a timeout while the tool
226
+ // is still working. The AI polls progress/results with the `jobs` tool.
227
+ const dispatched = await jobManager.dispatch(
228
+ { tool: name, action: args.action, label: `${name}${args.action ? `.${args.action}` : ''}` },
229
+ () => registry.call(name, args),
230
+ );
231
+
232
+ if (dispatched.done) {
233
+ if (dispatched.error) throw dispatched.error;
234
+ finalize(dispatched.result);
235
+ thinking.set('idle');
236
+ return dispatched.result;
237
+ }
238
+
239
+ // Backgrounded: tell XiaoZhi it's running and how to follow up. Finalize
240
+ // the real result once the background work completes.
241
+ const job = dispatched.job;
242
+ jobManager.wait(job.id, 3_600_000).then(() => {
243
+ if (job.status === 'done' && job.result) {
244
+ const wrapped = { isError: false, content: [{ type: 'text', text: typeof job.result === 'string' ? job.result : JSON.stringify(job.result) }] };
245
+ finalize(wrapped);
246
+ }
247
+ }).catch(() => {});
248
+
249
+ thinking.set('idle');
250
+ return {
251
+ isError: false,
252
+ content: [{ type: 'text', text: JSON.stringify({
253
+ status: 'running',
254
+ job_id: job.id,
255
+ tool: name,
256
+ action: args.action,
257
+ message: `"${name}${args.action ? `.${args.action}` : ''}" is running in the background (job ${job.id}). It is NOT an error or timeout. Poll it with the jobs tool: {"action":"wait","id":"${job.id}"} to wait, or {"action":"status","id":"${job.id}"} to check progress.`,
258
+ }) }],
259
+ };
152
260
  } catch (e) {
153
261
  stats.record(name, args.action, false, Date.now() - start, e.message);
154
262
  thinking.set('idle');
@@ -158,7 +266,7 @@ async function main() {
158
266
  () => registry.toMcpTools()
159
267
  );
160
268
 
161
- // 10. Optional WSS endpoint
269
+ // 11. Optional WSS endpoint
162
270
  let wss = null;
163
271
  if (config.mcp?.endpoint_url) {
164
272
  wss = new WssEndpoint(config.mcp.endpoint_url, () => registry.toMcpTools(), async (name, args) => {
@@ -168,25 +276,39 @@ async function main() {
168
276
  wss.start();
169
277
  }
170
278
 
171
- // 11. Track conversation
172
- bus.on('user:text', (text) => session.addUserMessage(text));
173
- bus.on('llm:text', (text) => session.addAiMessage(text));
279
+ // 12. Track conversation + log AI responses
280
+ bus.on('user:text', (text) => {
281
+ session.addUserMessage(text);
282
+ writeRawToLog(`[USER] ${text}`);
283
+ });
284
+ bus.on('llm:text', (text) => {
285
+ session.addAiMessage(text);
286
+ writeRawToLog(`[AI] ${text}`);
287
+ });
288
+ bus.on('tts:sentence', (text) => {
289
+ writeRawToLog(`[AI-TTS] ${text}`);
290
+ });
174
291
 
175
- // 12. Save config
292
+ // 13. Save config
176
293
  await saveConfig(ROOT, config);
177
294
 
178
- // 13. Start Ink TUI
295
+ // 14. Start Ink TUI
179
296
  const { unmount, waitUntilExit } = startInkTUI({
180
297
  registry, mqtt, stats, session, bus, config,
181
- extras: { fileTracker, thinking, sender, autoFormat, startupLogs },
298
+ extras: {
299
+ fileTracker, thinking, sender, autoFormat, startupLogs,
300
+ agentController, // Pass agent controller to TUI
301
+ },
182
302
  });
183
303
 
184
304
  // Graceful shutdown
185
305
  const shutdown = async () => {
186
306
  unmount();
307
+ if (agentController) agentController.abort();
187
308
  if (wss) wss.stop();
188
309
  await registry.shutdown();
189
310
  mqtt.disconnect();
311
+ await flushLogs();
190
312
  process.exit(0);
191
313
  };
192
314
  process.on('SIGINT', shutdown);
@@ -43,7 +43,7 @@ export class McpHandler {
43
43
  result: {
44
44
  protocolVersion: PROTOCOL_VERSION,
45
45
  capabilities: { tools: {} },
46
- serverInfo: { name: 'coding-agent', version: '1.0.0' },
46
+ serverInfo: { name: 'pelulu-cli', version: '1.0.0' },
47
47
  },
48
48
  },
49
49
  });
@@ -58,13 +58,18 @@ export class McpHandler {
58
58
  if (p.method === 'tools/list') {
59
59
  this.#toolsReceived = true;
60
60
  this.#nameMap.clear();
61
- const tools = (this.#toolsFn?.() || []).map(t => {
61
+ let tools = (this.#toolsFn?.() || []).map(t => {
62
62
  const safeName = this._sanitize(t.name);
63
63
  this.#nameMap.set(safeName, t.name);
64
- return { name: safeName, description: t.description, inputSchema: t.inputSchema || { type: 'object', properties: {} } };
64
+ return this._optimizeTool({ name: safeName, description: t.description, inputSchema: t.inputSchema || { type: 'object', properties: {} } });
65
65
  });
66
+ // Hard guard: the XiaoZhi broker disconnects the client if the
67
+ // tools/list payload exceeds ~8KB. Progressively shrink until it fits so
68
+ // a newly added tool can never silently break the whole connection.
69
+ tools = this._fitUnderLimit(tools, p.id);
70
+ const bytes = Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', id: p.id, result: { tools } }));
66
71
  responses.push({ type: 'mcp', payload: { jsonrpc: '2.0', id: p.id, result: { tools } } });
67
- log('mcp', `Sent ${tools.length} tools`);
72
+ log('mcp', `Sent ${tools.length} tools (${bytes} bytes)`);
68
73
  }
69
74
 
70
75
  if (p.method === 'tools/call') {
@@ -89,4 +94,58 @@ export class McpHandler {
89
94
  _sanitize(name) {
90
95
  return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
91
96
  }
97
+
98
+ /**
99
+ * Keep the serialized tools/list under the broker's ~8KB limit.
100
+ * Step 1: trim descriptions. Step 2: drop schema properties down to just
101
+ * `action`. We stop as soon as we're safely under the limit.
102
+ */
103
+ _fitUnderLimit(tools, id, limit = 7800) {
104
+ const size = (t) => Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', id, result: { tools: t } }));
105
+ if (size(tools) <= limit) return tools;
106
+
107
+ // Step 1: shorten descriptions to ~40 chars.
108
+ let trimmed = tools.map(t => ({ ...t, description: (t.description || '').slice(0, 40) }));
109
+ if (size(trimmed) <= limit) { log('warn', 'tools/list trimmed descriptions to fit 8KB'); return trimmed; }
110
+
111
+ // Step 2: reduce each schema to only the `action` property.
112
+ trimmed = trimmed.map(t => {
113
+ const props = t.inputSchema?.properties || {};
114
+ const minimal = props.action ? { action: props.action } : {};
115
+ return { ...t, description: (t.description || '').slice(0, 24), inputSchema: { type: 'object', properties: minimal } };
116
+ });
117
+ if (size(trimmed) <= limit) { log('warn', 'tools/list reduced schemas to fit 8KB'); return trimmed; }
118
+
119
+ // Step 3 (last resort): drop tools from the end until it fits.
120
+ while (trimmed.length > 1 && size(trimmed) > limit) trimmed.pop();
121
+ log('warn', `tools/list truncated to ${trimmed.length} tools to fit 8KB`);
122
+ return trimmed;
123
+ }
124
+
125
+ /**
126
+ * Optimize tool definition to stay under 8KB MQTT broker limit
127
+ * - Keep descriptions (truncated to 200 chars)
128
+ * - Keep enum values (critical for XiaoZhi to know valid actions)
129
+ * - Keep required fields (critical for XiaoZhi to know what's mandatory)
130
+ */
131
+ _optimizeTool(tool) {
132
+ const optimized = {
133
+ name: tool.name,
134
+ description: tool.description?.slice(0, 100) || '',
135
+ inputSchema: { type: 'object', properties: {} }
136
+ };
137
+
138
+ // Keep first 4 properties only (to stay under 8KB MQTT limit)
139
+ // action + first 3 params — enough for XiaoZhi to understand the tool
140
+ if (tool.inputSchema?.properties) {
141
+ const entries = Object.entries(tool.inputSchema.properties);
142
+ for (const [key, val] of entries.slice(0, 4)) {
143
+ const prop = { type: val.type };
144
+ if (val.enum) prop.enum = val.enum;
145
+ optimized.inputSchema.properties[key] = prop;
146
+ }
147
+ }
148
+
149
+ return optimized;
150
+ }
92
151
  }
@@ -9,6 +9,13 @@ import { bus } from '../core/event-bus.js';
9
9
  import { McpHandler } from './mcp-handler.js';
10
10
  import { fetchOtaConfig, handleActivation } from './activation.js';
11
11
 
12
+ // Max serialized bytes for a single MQTT publish. The XiaoZhi broker drops the
13
+ // connection the instant a message crosses ~8KB (see specifications/
14
+ // xiaozhi-mqtt-broker.md). tools/list is already guarded; tool RESULTS must be
15
+ // clamped here too, otherwise a single large file.read / recursive list / shell
16
+ // dump silently kills the whole connection.
17
+ const MAX_MQTT_BYTES = 7800;
18
+
12
19
  export class MqttClient {
13
20
  constructor(config) {
14
21
  this.config = config;
@@ -20,13 +27,38 @@ export class MqttClient {
20
27
  this.mqttCfg = null;
21
28
  this.mcp = new McpHandler();
22
29
  this._helloQueue = [];
30
+ // Reconnection state — we manage reconnect ourselves because the broker
31
+ // hands out FRESH credentials from OTA on every connection (cached creds
32
+ // are rejected), so mqtt.js's built-in reconnect can never recover.
33
+ this._manualClose = false;
34
+ this._reconnectTimer = null;
35
+ this._reconnectAttempts = 0;
36
+ // Whether an agent turn is actively in progress. XiaoZhi closes idle audio
37
+ // sessions with a `goodbye`; if that lands while we're still working we must
38
+ // keep the session warm (re-`hello`) instead of letting the task stall until
39
+ // the next user message. Driven by the agent-loop's progress events.
40
+ this._turnActive = false;
41
+ this._resuming = false;
42
+ bus.on('agent:progress', ({ state } = {}) => {
43
+ if (state === 'done' || state === 'timeout') this._turnActive = false;
44
+ else if (state) this._turnActive = true; // thinking/tool/tool_done/receiving
45
+ });
23
46
  }
24
47
 
25
48
  async connect() {
26
49
  if (!this.deviceId) this.deviceId = this._randomMac();
27
50
  if (!this.clientId) this.clientId = crypto.randomUUID();
28
51
  await this._persistIds();
52
+ this._manualClose = false;
53
+ return this._refreshAndConnect();
54
+ }
29
55
 
56
+ /**
57
+ * Fetch fresh OTA credentials, then (re)connect. Used for the initial
58
+ * connection AND every reconnect — the broker requires new credentials
59
+ * each time, so we must always re-run the OTA handshake.
60
+ */
61
+ async _refreshAndConnect() {
30
62
  log('info', 'Fetching device config...');
31
63
  const data = await fetchOtaConfig(this.config.mqtt.ota_url, this.deviceId, this.clientId);
32
64
  this.mqttCfg = await handleActivation(data, this.config.mqtt.ota_url, this.deviceId, this.clientId);
@@ -46,42 +78,86 @@ export class MqttClient {
46
78
 
47
79
  _connectMqtt() {
48
80
  return new Promise((resolve, reject) => {
81
+ // Tear down any previous client so we never leak listeners / sockets.
82
+ if (this.client) {
83
+ try { this.client.removeAllListeners(); this.client.end(true); } catch {}
84
+ this.client = null;
85
+ }
86
+
49
87
  this.client = mqtt.connect(`mqtts://${this.mqttCfg.endpoint}:8883`, {
50
88
  clientId: this.mqttCfg.client_id,
51
89
  username: this.mqttCfg.username,
52
90
  password: this.mqttCfg.password,
53
- keepalive: 240,
54
- reconnectPeriod: 5000,
55
- connectTimeout: 10000,
91
+ keepalive: 60,
92
+ reconnectPeriod: 0, // we manage reconnect ourselves (fresh OTA creds required)
93
+ connectTimeout: 15000,
56
94
  clean: true,
95
+ protocolVersion: 4, // MQTT v3.1.1
57
96
  });
58
97
 
98
+ let settled = false;
99
+
59
100
  this.client.on('connect', () => {
60
101
  this.connected = true;
102
+ this._reconnectAttempts = 0;
61
103
  log('ok', 'MQTT Connected');
62
104
  this.client.subscribe('devices/p2p/#');
63
105
  this._sendHello();
64
- resolve();
106
+ bus.emit('mqtt:connected');
107
+ if (!settled) { settled = true; resolve(); }
65
108
  });
66
109
 
67
110
  this.client.on('message', (_, raw) => this._onMessage(raw));
68
111
  this.client.on('error', e => { log('err', `MQTT: ${e.message}`); bus.emit('mqtt:error', e); });
69
112
  this.client.on('close', () => {
113
+ const wasConnected = this.connected;
70
114
  this.connected = false;
71
115
  this.sessionId = null;
116
+ // A dropped MQTT connection invalidates the whole MCP handshake — the
117
+ // server re-runs initialize/tools/list on the next connection, so a
118
+ // full reset here is correct (unlike a `goodbye`, see _onOther).
72
119
  this.mcp.reset();
73
120
  this._helloQueue = [];
74
- log('warn', 'MQTT Disconnected');
121
+ if (wasConnected) log('warn', 'MQTT Disconnected');
122
+ bus.emit('mqtt:disconnected');
123
+ // Kick off our own reconnect (fresh OTA credentials) unless the user
124
+ // asked to disconnect. This is what recovers from the sudden drops.
125
+ if (!this._manualClose) this._scheduleReconnect();
126
+ if (!settled) { settled = true; reject(new Error('MQTT connection closed before ready')); }
75
127
  });
76
- this.client.on('reconnect', () => log('info', 'MQTT Reconnecting...'));
77
128
  });
78
129
  }
79
130
 
131
+ /**
132
+ * Reconnect with exponential backoff. Each attempt re-fetches OTA config so
133
+ * we always present valid, fresh credentials to the broker.
134
+ */
135
+ _scheduleReconnect() {
136
+ if (this._reconnectTimer || this._manualClose) return;
137
+ this._reconnectAttempts++;
138
+ const delay = Math.min(30000, 2000 * Math.pow(1.6, Math.min(this._reconnectAttempts - 1, 6)));
139
+ log('info', `MQTT reconnecting in ${Math.round(delay / 1000)}s (attempt ${this._reconnectAttempts})...`);
140
+ bus.emit('mqtt:reconnecting', { attempt: this._reconnectAttempts, delay });
141
+ this._reconnectTimer = setTimeout(async () => {
142
+ this._reconnectTimer = null;
143
+ if (this._manualClose) return;
144
+ try {
145
+ await this._refreshAndConnect();
146
+ log('ok', 'MQTT reconnected');
147
+ bus.emit('mqtt:reconnected');
148
+ } catch (e) {
149
+ log('err', `Reconnect failed: ${e.message}`);
150
+ this._scheduleReconnect();
151
+ }
152
+ }, delay);
153
+ }
154
+
80
155
  _sendHello() {
81
156
  this.client.publish(this.mqttCfg.publish_topic, JSON.stringify({
82
157
  type: 'hello', version: 3, transport: 'udp',
83
158
  features: { mcp: true },
84
159
  audio_params: { format: 'opus', sample_rate: 16000, channels: 1, frame_duration: 60 },
160
+ system_prompt: 'You are Pelulu, a CLI coding agent. When the user asks to create, write, or edit files, you MUST use the file tool (action write/edit/mkdir) — actually do it, never just describe it. For multi-file work, call the tools one after another until the whole task is done. When you finish, ALWAYS say one short sentence confirming what you did (e.g. "Done, created 3 files.") so the client knows the turn is complete. Keep spoken replies short.',
85
161
  }));
86
162
  }
87
163
 
@@ -112,9 +188,20 @@ export class MqttClient {
112
188
  if (r.type === 'mcp') { this._send(r); }
113
189
  else if (r.type === 'tool_call') {
114
190
  log('tool', r.name);
191
+ // Emit tool call event so agent can track it
192
+ bus.emit('mcp:tool_call', { name: r.name, args: r.args, id: r.id });
115
193
  this.mcp.executeTool(r.name, r.args)
116
- .then(result => { log('tool', result.isError ? 'failed' : 'done'); this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } }); })
117
- .catch(err => { this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: err.message }], isError: true } } }); });
194
+ .then(result => {
195
+ log('tool', result.isError ? 'failed' : 'done');
196
+ this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: this._clampResult(result) } });
197
+ // Emit tool result event
198
+ bus.emit('mcp:tool_result', { name: r.name, args: r.args, result, id: r.id });
199
+ })
200
+ .catch(err => {
201
+ const errResult = { content: [{ type: 'text', text: err.message }], isError: true };
202
+ this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: this._clampResult(errResult) } });
203
+ bus.emit('mcp:tool_result', { name: r.name, args: r.args, result: errResult, id: r.id });
204
+ });
118
205
  }
119
206
  }
120
207
  if (this.mcp.toolsReceived && this._helloQueue.length) {
@@ -127,19 +214,110 @@ export class MqttClient {
127
214
  if (msg.type === 'stt') { log('user', `"${msg.text}"`); bus.emit('stt', msg.text); }
128
215
  if (msg.type === 'llm' && msg.text) { bus.emit('llm:text', msg.text); }
129
216
  if (msg.type === 'tts' && msg.state === 'sentence_start' && msg.text) { bus.emit('tts:sentence', msg.text); }
130
- if (msg.type === 'goodbye') { this.sessionId = null; this.mcp.reset(); this._helloQueue = []; }
217
+ if (msg.type === 'goodbye') {
218
+ // A server `goodbye` only closes the audio SESSION — the MQTT connection
219
+ // and the MCP handshake (tools/list) remain valid. So we must NOT reset
220
+ // the MCP handler here; doing so made ensureSession() wait forever for a
221
+ // tools/list that never comes again, which looked like a permanent
222
+ // disconnect. We only drop the session id; the next message re-opens a
223
+ // session via a fresh hello (see ensureSession).
224
+ this.sessionId = null;
225
+ bus.emit('session:end', { turnActive: this._turnActive });
226
+ // Stay connected while there is still work in progress. If a turn is
227
+ // active we immediately re-open the session so the current task keeps
228
+ // talking to XiaoZhi, instead of stalling until the user types again.
229
+ // The MCP handshake is still valid (goodbye only closes the audio
230
+ // session), so this is a cheap `hello` round-trip, not a full reconnect.
231
+ if (this._turnActive && !this._manualClose && this.mcp.toolsReceived && !this._resuming) {
232
+ this._resuming = true;
233
+ this.ensureSession()
234
+ .then(ok => { if (ok) bus.emit('session:resumed'); })
235
+ .catch(() => {})
236
+ .finally(() => { this._resuming = false; });
237
+ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Clamp an MCP tool-result so the serialized MQTT message stays under the
243
+ * broker's ~8KB limit. Oversized output (large file reads, recursive
244
+ * listings, shell dumps, search hits) would otherwise drop the whole
245
+ * connection the moment it's published. We truncate the result text and
246
+ * append a clear notice so the model knows the output was cut and can fetch
247
+ * the rest with a narrower request (e.g. offset/limit on file reads).
248
+ */
249
+ _clampResult(result) {
250
+ const measure = (res) => Buffer.byteLength(JSON.stringify({
251
+ type: 'mcp', payload: { jsonrpc: '2.0', id: 0, result: res }, session_id: this.sessionId || '',
252
+ }));
253
+ try {
254
+ if (measure(result) <= MAX_MQTT_BYTES) return result;
255
+
256
+ const text = result?.content?.[0]?.text;
257
+ if (typeof text !== 'string') return result; // can't safely trim non-text
258
+
259
+ const notice = '\n\n[output truncated: exceeded the 8KB transport limit — request less at once (e.g. use offset/limit for file reads or a narrower query)]';
260
+ // Binary-search the largest text prefix that still fits once re-wrapped.
261
+ let lo = 0, hi = text.length, best = 0;
262
+ while (lo <= hi) {
263
+ const mid = (lo + hi) >> 1;
264
+ const candidate = { ...result, content: [{ type: 'text', text: text.slice(0, mid) + notice }] };
265
+ if (measure(candidate) <= MAX_MQTT_BYTES) { best = mid; lo = mid + 1; }
266
+ else { hi = mid - 1; }
267
+ }
268
+ log('warn', `Tool result truncated to ${best}/${text.length} chars to fit 8KB`);
269
+ return { ...result, content: [{ type: 'text', text: text.slice(0, best) + notice }] };
270
+ } catch { return result; }
131
271
  }
132
272
 
133
273
  _send(msg) {
134
274
  if (!this.client || !this.mqttCfg) return;
135
275
  if (this.sessionId) msg.session_id = this.sessionId;
136
- this.client.publish(this.mqttCfg.publish_topic, JSON.stringify(msg));
276
+ const payload = JSON.stringify(msg);
277
+ // Final safety net: never publish a message that would trip the broker's
278
+ // ~8KB cutoff and drop the connection. Results are pre-clamped above; this
279
+ // catches any other oversized message type before it does damage.
280
+ if (Buffer.byteLength(payload) > MAX_MQTT_BYTES) {
281
+ log('warn', `Dropping oversized MQTT message (${Buffer.byteLength(payload)} bytes > ${MAX_MQTT_BYTES})`);
282
+ return;
283
+ }
284
+ this.client.publish(this.mqttCfg.publish_topic, payload);
137
285
  }
138
286
 
139
- sendText(text) {
287
+ /**
288
+ * Ensure a live XiaoZhi session exists before sending.
289
+ * XiaoZhi ends idle sessions with `goodbye` (common during long local
290
+ * tasks). When that happens we must re-send `hello` to get a fresh session
291
+ * + MCP handshake, otherwise all further messages are silently dropped.
292
+ * Resolves true once ready, false on timeout.
293
+ */
294
+ async ensureSession(timeoutMs = 15000) {
295
+ if (this.sessionId && this.mcp.toolsReceived) return true;
296
+ if (!this.client) return false;
297
+
298
+ // Re-initiate the handshake (server replies with a new hello + session_id)
299
+ this._sendHello();
300
+
301
+ return new Promise((resolve) => {
302
+ if (this.sessionId && this.mcp.toolsReceived) return resolve(true);
303
+ const onReady = () => { cleanup(); resolve(true); };
304
+ const poll = setInterval(() => {
305
+ if (this.sessionId && this.mcp.toolsReceived) { cleanup(); resolve(true); }
306
+ }, 200);
307
+ const timer = setTimeout(() => { cleanup(); resolve(false); }, timeoutMs);
308
+ const cleanup = () => { clearInterval(poll); clearTimeout(timer); bus.off('ready', onReady); };
309
+ bus.on('ready', onReady);
310
+ });
311
+ }
312
+
313
+ async sendText(text) {
314
+ // Re-establish the session if XiaoZhi dropped it while idle.
315
+ const ok = await this.ensureSession();
316
+ if (!ok) { bus.emit('session:dead'); return false; }
140
317
  this._send({ type: 'listen', state: 'detect', text });
141
318
  log('user', `"${text}"`);
142
319
  bus.emit('user:text', text);
320
+ return true;
143
321
  }
144
322
 
145
323
  abort() { this._send({ type: 'abort' }); }
@@ -154,7 +332,12 @@ export class MqttClient {
154
332
  }
155
333
 
156
334
  disconnect() {
157
- if (this.client) { this.client.end(true); this.client = null; }
335
+ this._manualClose = true;
336
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
337
+ if (this.client) {
338
+ try { this.client.removeAllListeners(); this.client.end(true); } catch {}
339
+ this.client = null;
340
+ }
158
341
  this.connected = false;
159
342
  }
160
343
  }