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 +81 -0
- package/package.json +10 -6
- package/src/agent/agent-controller.js +105 -0
- package/src/agent/agent-loop.js +248 -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 +44 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- package/src/core/config.js +1 -1
- package/src/core/http-client.js +285 -0
- package/src/core/job-manager.js +192 -0
- package/src/core/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +65 -6
- package/src/index.js +151 -29
- package/src/mcp/mcp-handler.js +63 -4
- package/src/mcp/mqtt-client.js +195 -12
- package/src/tools/agent.js +80 -0
- package/src/tools/file.js +8 -2
- package/src/tools/git.js +56 -6
- package/src/tools/jobs.js +93 -0
- package/src/tools/network.js +56 -23
- package/src/tools/project.js +38 -4
- package/src/tools/search.js +10 -14
- package/src/tools/shell.js +70 -4
- package/src/tui/completable-input.js +49 -42
- package/src/tui/ink-app.js +358 -19
- package/src/tui/ink-components.js +82 -12
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
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 {
|
|
10
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
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.
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
172
|
-
bus.on('user:text', (text) =>
|
|
173
|
-
|
|
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
|
-
//
|
|
292
|
+
// 13. Save config
|
|
176
293
|
await saveConfig(ROOT, config);
|
|
177
294
|
|
|
178
|
-
//
|
|
295
|
+
// 14. Start Ink TUI
|
|
179
296
|
const { unmount, waitUntilExit } = startInkTUI({
|
|
180
297
|
registry, mqtt, stats, session, bus, config,
|
|
181
|
-
extras: {
|
|
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);
|
package/src/mcp/mcp-handler.js
CHANGED
|
@@ -43,7 +43,7 @@ export class McpHandler {
|
|
|
43
43
|
result: {
|
|
44
44
|
protocolVersion: PROTOCOL_VERSION,
|
|
45
45
|
capabilities: { tools: {} },
|
|
46
|
-
serverInfo: { name: '
|
|
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
|
-
|
|
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
|
}
|
package/src/mcp/mqtt-client.js
CHANGED
|
@@ -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:
|
|
54
|
-
reconnectPeriod:
|
|
55
|
-
connectTimeout:
|
|
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
|
-
|
|
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 => {
|
|
117
|
-
|
|
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') {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|