pelulu-cli 1.0.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/README.md +273 -0
- package/config.example.json +24 -0
- package/package.json +44 -0
- package/src/core/auto-format.js +47 -0
- package/src/core/completion.js +65 -0
- package/src/core/config.js +68 -0
- package/src/core/confirm.js +51 -0
- package/src/core/context.js +69 -0
- package/src/core/conversation.js +57 -0
- package/src/core/diff.js +50 -0
- package/src/core/doctor.js +60 -0
- package/src/core/error-handler.js +39 -0
- package/src/core/event-bus.js +43 -0
- package/src/core/file-tracker.js +44 -0
- package/src/core/formatter.js +83 -0
- package/src/core/intent.js +81 -0
- package/src/core/keybindings.js +29 -0
- package/src/core/logger.js +67 -0
- package/src/core/model-info.js +26 -0
- package/src/core/retry.js +26 -0
- package/src/core/sandbox.js +54 -0
- package/src/core/session.js +46 -0
- package/src/core/spinner.js +60 -0
- package/src/core/stats.js +73 -0
- package/src/core/system-prompt.js +42 -0
- package/src/core/thinking.js +39 -0
- package/src/core/tool-help.js +117 -0
- package/src/core/tool-registry.js +82 -0
- package/src/core/wizard.js +55 -0
- package/src/core/workspace.js +96 -0
- package/src/index.js +150 -0
- package/src/mcp/activation.js +54 -0
- package/src/mcp/mcp-handler.js +92 -0
- package/src/mcp/message-sender.js +147 -0
- package/src/mcp/mqtt-client.js +160 -0
- package/src/mcp/wss-endpoint.js +133 -0
- package/src/plugins/manager.js +72 -0
- package/src/repl-commands.js +91 -0
- package/src/repl.js +115 -0
- package/src/tools/ai.js +128 -0
- package/src/tools/config.js +87 -0
- package/src/tools/diff.js +118 -0
- package/src/tools/env.js +58 -0
- package/src/tools/file.js +177 -0
- package/src/tools/git.js +166 -0
- package/src/tools/history.js +64 -0
- package/src/tools/network.js +87 -0
- package/src/tools/process.js +69 -0
- package/src/tools/project.js +152 -0
- package/src/tools/search.js +100 -0
- package/src/tools/shell.js +91 -0
- package/src/tools/snippet.js +95 -0
- package/src/tools/template.js +113 -0
- package/src/tools/watch.js +87 -0
- package/src/tui/renderer.js +119 -0
- package/src/tui/status-bar.js +34 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* XiaoZhi Coding Agent — CLI Entry Point
|
|
4
|
+
* Usage: cd my-project && xcode
|
|
5
|
+
*/
|
|
6
|
+
import { dirname, join } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { loadConfig, saveConfig } from './core/config.js';
|
|
9
|
+
import { log, setDebug } from './core/logger.js';
|
|
10
|
+
import { bus } from './core/event-bus.js';
|
|
11
|
+
import { ToolRegistry } from './core/tool-registry.js';
|
|
12
|
+
import { Sandbox } from './core/sandbox.js';
|
|
13
|
+
import { SessionState } from './core/session.js';
|
|
14
|
+
import { Stats } from './core/stats.js';
|
|
15
|
+
import { buildSystemPrompt } from './core/system-prompt.js';
|
|
16
|
+
import { buildContext } from './core/context.js';
|
|
17
|
+
import { isDestructive, askConfirmation } from './core/confirm.js';
|
|
18
|
+
import { runWizard } from './core/wizard.js';
|
|
19
|
+
import { MqttClient } from './mcp/mqtt-client.js';
|
|
20
|
+
import { WssEndpoint } from './mcp/wss-endpoint.js';
|
|
21
|
+
import { PluginManager } from './plugins/manager.js';
|
|
22
|
+
import { REPL } from './repl.js';
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const ROOT = join(__dirname, '..');
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
if (args.includes('--debug')) setDebug(true);
|
|
29
|
+
if (args.includes('--wizard')) { /* force wizard */ }
|
|
30
|
+
const LIST_TOOLS = args.includes('--list-tools');
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
// 1. Load config + wizard
|
|
34
|
+
const config = await loadConfig(ROOT);
|
|
35
|
+
|
|
36
|
+
// 2. Auto-detect CWD as workspace (like Claude Code, Gemini CLI, etc.)
|
|
37
|
+
const cwd = process.cwd();
|
|
38
|
+
config.agent = { ...config.agent, workspace: cwd };
|
|
39
|
+
log('info', `Workspace: ${cwd}`);
|
|
40
|
+
|
|
41
|
+
if (!config._wizard_done || args.includes('--wizard')) {
|
|
42
|
+
await runWizard(ROOT);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. Load tools
|
|
46
|
+
const registry = new ToolRegistry();
|
|
47
|
+
log('info', 'Loading tools...');
|
|
48
|
+
await registry.loadBuiltins();
|
|
49
|
+
|
|
50
|
+
// 4. Load plugins
|
|
51
|
+
const plugins = new PluginManager(registry);
|
|
52
|
+
await plugins.load();
|
|
53
|
+
|
|
54
|
+
if (LIST_TOOLS) {
|
|
55
|
+
const tools = registry.list();
|
|
56
|
+
console.log('\n🔧 Tools:\n');
|
|
57
|
+
for (const t of tools) console.log(` ${t.name} — ${t.description} (${t.actions.join(', ')})`);
|
|
58
|
+
console.log(`\n ${tools.length} tools, ${tools.reduce((s, t) => s + t.actions.length, 0)} actions\n`);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 5. Initialize systems
|
|
63
|
+
const sandbox = new Sandbox();
|
|
64
|
+
const session = new SessionState();
|
|
65
|
+
const stats = new Stats();
|
|
66
|
+
|
|
67
|
+
// 6. Build context & system prompt
|
|
68
|
+
const context = await buildContext();
|
|
69
|
+
const systemPrompt = buildSystemPrompt(registry, config);
|
|
70
|
+
log('info', `Context: ${context.split('\n').length} lines`);
|
|
71
|
+
|
|
72
|
+
// 7. Connect to XiaoZhi
|
|
73
|
+
const mqtt = new MqttClient(config);
|
|
74
|
+
|
|
75
|
+
bus.on('activation:required', ({ code }) => {
|
|
76
|
+
console.log('');
|
|
77
|
+
console.log(` ╔═════════════════════════════════════════════╗`);
|
|
78
|
+
console.log(` ║ 🔐 Kode Aktivasi: ${String(code).padEnd(25)}║`);
|
|
79
|
+
console.log(` ║ 🌐 https://xiaozhi.me ║`);
|
|
80
|
+
console.log(` ╚═════════════════════════════════════════════╝`);
|
|
81
|
+
console.log('');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
log('info', 'Connecting to XiaoZhi...');
|
|
86
|
+
await mqtt.connect();
|
|
87
|
+
} catch (e) {
|
|
88
|
+
log('err', `Connection failed: ${e.message}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 8. Register MCP tool handler
|
|
93
|
+
mqtt.registerToolHandler(
|
|
94
|
+
async (name, args) => {
|
|
95
|
+
const destructive = isDestructive(name, args);
|
|
96
|
+
if (destructive.destructive) {
|
|
97
|
+
const ok = await askConfirmation(name, args, destructive);
|
|
98
|
+
if (!ok) return { isError: true, content: [{ type: 'text', text: 'Cancelled by user' }] };
|
|
99
|
+
}
|
|
100
|
+
sandbox.validate(name, args);
|
|
101
|
+
const start = Date.now();
|
|
102
|
+
log('tool', `🔧 ${name} → ${args.action || '-'}`);
|
|
103
|
+
try {
|
|
104
|
+
const result = await registry.call(name, args);
|
|
105
|
+
stats.record(name, args.action, !result.isError, Date.now() - start);
|
|
106
|
+
session.addToolCall(name, args, result);
|
|
107
|
+
log('tool', result.isError ? `❌ ${result.content?.[0]?.text}` : `✅ OK`);
|
|
108
|
+
bus.emit('tool:called', { name, result, args });
|
|
109
|
+
return result;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
stats.record(name, args.action, false, Date.now() - start, e.message);
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
() => registry.toMcpTools()
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// 9. Optional WSS endpoint
|
|
119
|
+
let wss = null;
|
|
120
|
+
if (config.mcp?.endpoint_url) {
|
|
121
|
+
wss = new WssEndpoint(config.mcp.endpoint_url, () => registry.toMcpTools(), async (name, args) => {
|
|
122
|
+
sandbox.validate(name, args);
|
|
123
|
+
return registry.call(name, args);
|
|
124
|
+
});
|
|
125
|
+
wss.start();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 10. Track conversation
|
|
129
|
+
bus.on('user:text', (text) => session.addUserMessage(text));
|
|
130
|
+
bus.on('llm:text', (text) => session.addAiMessage(text));
|
|
131
|
+
|
|
132
|
+
// 11. Save config & start REPL
|
|
133
|
+
await saveConfig(ROOT, config);
|
|
134
|
+
const repl = new REPL(registry, mqtt, stats, session);
|
|
135
|
+
repl.start();
|
|
136
|
+
|
|
137
|
+
// Graceful shutdown
|
|
138
|
+
const shutdown = async () => {
|
|
139
|
+
log('info', 'Shutting down...');
|
|
140
|
+
console.log(stats.formatReport());
|
|
141
|
+
if (wss) wss.stop();
|
|
142
|
+
await registry.shutdown();
|
|
143
|
+
mqtt.disconnect();
|
|
144
|
+
process.exit(0);
|
|
145
|
+
};
|
|
146
|
+
process.on('SIGINT', shutdown);
|
|
147
|
+
process.on('SIGTERM', shutdown);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
main().catch(e => { log('err', `Fatal: ${e.message}`); process.exit(1); });
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activation — XiaoZhi device activation flow
|
|
3
|
+
* Handles: code display, polling, timeout
|
|
4
|
+
*/
|
|
5
|
+
import https from 'https';
|
|
6
|
+
import { log } from '../core/logger.js';
|
|
7
|
+
import { bus } from '../core/event-bus.js';
|
|
8
|
+
|
|
9
|
+
function httpPost(url, body, headers = {}) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const req = https.request(new URL(url), {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
14
|
+
}, (res) => {
|
|
15
|
+
let d = '';
|
|
16
|
+
res.on('data', c => d += c);
|
|
17
|
+
res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
|
18
|
+
});
|
|
19
|
+
req.on('error', reject);
|
|
20
|
+
req.write(JSON.stringify(body));
|
|
21
|
+
req.end();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function fetchOtaConfig(otaUrl, deviceId, clientId) {
|
|
26
|
+
return httpPost(otaUrl, {
|
|
27
|
+
application: { version: '1.0.0' },
|
|
28
|
+
board: { type: 'linux', name: 'coding-agent', mac: deviceId },
|
|
29
|
+
}, { 'Device-Id': deviceId, 'Client-Id': clientId });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function handleActivation(data, otaUrl, deviceId, clientId) {
|
|
33
|
+
const a = data.activation;
|
|
34
|
+
if (!a?.code) return data.mqtt;
|
|
35
|
+
|
|
36
|
+
log('warn', `🔐 Activation required`);
|
|
37
|
+
log('info', `📋 Code: ${a.code}`);
|
|
38
|
+
log('info', `🌐 https://xiaozhi.me`);
|
|
39
|
+
bus.emit('activation:required', { code: a.code });
|
|
40
|
+
|
|
41
|
+
const timeout = a.timeout_ms || 120000;
|
|
42
|
+
const start = Date.now();
|
|
43
|
+
|
|
44
|
+
while (Date.now() - start < timeout) {
|
|
45
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
46
|
+
log('info', '⏳ Checking activation...');
|
|
47
|
+
const poll = await fetchOtaConfig(otaUrl, deviceId, clientId);
|
|
48
|
+
if (!poll.activation?.code && poll.mqtt) {
|
|
49
|
+
log('ok', '✅ Activated!');
|
|
50
|
+
return poll.mqtt;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error('Activation timed out');
|
|
54
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* McpHandler — MCP protocol message handler
|
|
3
|
+
* Handles: initialize, tools/list, tools/call, ping
|
|
4
|
+
*/
|
|
5
|
+
import { log, debug } from '../core/logger.js';
|
|
6
|
+
|
|
7
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
8
|
+
|
|
9
|
+
export class McpHandler {
|
|
10
|
+
#toolsFn;
|
|
11
|
+
#toolHandler;
|
|
12
|
+
#nameMap = new Map();
|
|
13
|
+
#initialized = false;
|
|
14
|
+
#toolsReceived = false;
|
|
15
|
+
|
|
16
|
+
constructor() {}
|
|
17
|
+
|
|
18
|
+
setToolProvider(toolsFn) { this.#toolsFn = toolsFn; }
|
|
19
|
+
setToolCaller(handler) { this.#toolHandler = handler; }
|
|
20
|
+
|
|
21
|
+
get initialized() { return this.#initialized; }
|
|
22
|
+
get toolsReceived() { return this.#toolsReceived; }
|
|
23
|
+
|
|
24
|
+
reset() {
|
|
25
|
+
this.#initialized = false;
|
|
26
|
+
this.#toolsReceived = false;
|
|
27
|
+
this.#nameMap.clear();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handle incoming MCP message, return response(s) to send
|
|
32
|
+
*/
|
|
33
|
+
handleMessage(msg) {
|
|
34
|
+
const p = msg.payload;
|
|
35
|
+
if (!p) return [];
|
|
36
|
+
|
|
37
|
+
const responses = [];
|
|
38
|
+
|
|
39
|
+
if (p.method === 'initialize') {
|
|
40
|
+
responses.push({
|
|
41
|
+
type: 'mcp', payload: {
|
|
42
|
+
jsonrpc: '2.0', id: p.id,
|
|
43
|
+
result: {
|
|
44
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
45
|
+
capabilities: { tools: {} },
|
|
46
|
+
serverInfo: { name: 'coding-agent', version: '1.0.0' },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
log('mcp', 'Initialize OK');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (p.method === 'notifications/initialized') {
|
|
54
|
+
this.#initialized = true;
|
|
55
|
+
log('mcp', 'Initialized ✓');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (p.method === 'tools/list') {
|
|
59
|
+
this.#toolsReceived = true;
|
|
60
|
+
this.#nameMap.clear();
|
|
61
|
+
const tools = (this.#toolsFn?.() || []).map(t => {
|
|
62
|
+
const safeName = this._sanitize(t.name);
|
|
63
|
+
this.#nameMap.set(safeName, t.name);
|
|
64
|
+
return { name: safeName, description: t.description, inputSchema: t.inputSchema || { type: 'object', properties: {} } };
|
|
65
|
+
});
|
|
66
|
+
responses.push({ type: 'mcp', payload: { jsonrpc: '2.0', id: p.id, result: { tools } } });
|
|
67
|
+
log('mcp', `Sent ${tools.length} tools`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (p.method === 'tools/call') {
|
|
71
|
+
// Return a deferred response — caller must handle async
|
|
72
|
+
const requested = p.params?.name;
|
|
73
|
+
const name = this.#nameMap.get(requested) || requested;
|
|
74
|
+
return [{ type: 'tool_call', id: p.id, name, args: p.params?.arguments || {} }];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (p.method === 'ping') {
|
|
78
|
+
responses.push({ type: 'mcp', payload: { jsonrpc: '2.0', id: p.id, result: {} } });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return responses;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async executeTool(name, args) {
|
|
85
|
+
if (!this.#toolHandler) throw new Error('No tool handler registered');
|
|
86
|
+
return this.#toolHandler(name, args);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_sanitize(name) {
|
|
90
|
+
return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageSender — handles sending messages to XiaoZhi with proper timing
|
|
3
|
+
* Ensures MCP handshake is complete before sending
|
|
4
|
+
* Waits for tool calls to finish before sending next message
|
|
5
|
+
* Retries on timeout
|
|
6
|
+
*/
|
|
7
|
+
import { log, debug } from '../core/logger.js';
|
|
8
|
+
import { bus } from '../core/event-bus.js';
|
|
9
|
+
|
|
10
|
+
export class MessageSender {
|
|
11
|
+
constructor(mqtt) {
|
|
12
|
+
this.mqtt = mqtt;
|
|
13
|
+
this.queue = [];
|
|
14
|
+
this.sending = false;
|
|
15
|
+
this.waitingForResponse = false;
|
|
16
|
+
this.lastToolCallTime = 0;
|
|
17
|
+
this.toolCallCount = 0;
|
|
18
|
+
this.responseTimeout = null;
|
|
19
|
+
this.retryCount = 0;
|
|
20
|
+
this.maxRetries = 2;
|
|
21
|
+
this.responseWaitMs = 60000; // Wait up to 60s for response
|
|
22
|
+
this.toolCooldownMs = 3000; // Wait 3s after last tool call
|
|
23
|
+
|
|
24
|
+
// Track tool calls
|
|
25
|
+
bus.on('tool:called', () => {
|
|
26
|
+
this.lastToolCallTime = Date.now();
|
|
27
|
+
this.toolCallCount++;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send a message, waiting for MCP handshake if needed
|
|
33
|
+
*/
|
|
34
|
+
async send(text, options = {}) {
|
|
35
|
+
const { timeout = 120000, waitForTools = true, retries = 2 } = options;
|
|
36
|
+
|
|
37
|
+
// Wait for MCP handshake
|
|
38
|
+
await this._waitForReady();
|
|
39
|
+
|
|
40
|
+
log('info', `📤 Sending: "${text.slice(0, 80)}..."`);
|
|
41
|
+
this.mqtt.sendText(text);
|
|
42
|
+
|
|
43
|
+
if (!waitForTools) return { sent: true };
|
|
44
|
+
|
|
45
|
+
// Wait for response
|
|
46
|
+
return this._waitForResponse(timeout, retries);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Send multiple messages sequentially
|
|
51
|
+
*/
|
|
52
|
+
async sendSequence(messages, options = {}) {
|
|
53
|
+
const results = [];
|
|
54
|
+
for (let i = 0; i < messages.length; i++) {
|
|
55
|
+
log('info', `\n📨 [${i + 1}/${messages.length}]`);
|
|
56
|
+
const result = await this.send(messages[i], options);
|
|
57
|
+
results.push(result);
|
|
58
|
+
if (result.timeout && !options.continueOnTimeout) {
|
|
59
|
+
log('warn', `Timeout on message ${i + 1}, stopping sequence`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
// Cool down between messages
|
|
63
|
+
if (i < messages.length - 1) {
|
|
64
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async _waitForReady() {
|
|
71
|
+
if (this.mqtt.mcp.toolsReceived) return;
|
|
72
|
+
|
|
73
|
+
log('info', '⏳ Waiting for MCP handshake...');
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timeout = setTimeout(() => reject(new Error('MCP handshake timeout')), 30000);
|
|
76
|
+
const check = setInterval(() => {
|
|
77
|
+
if (this.mqtt.mcp.toolsReceived) {
|
|
78
|
+
clearInterval(check);
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
log('ok', 'MCP ready');
|
|
81
|
+
resolve();
|
|
82
|
+
}
|
|
83
|
+
}, 500);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async _waitForResponse(timeoutMs, maxRetries) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const startTime = Date.now();
|
|
90
|
+
let toolCallsBefore = this.toolCallCount;
|
|
91
|
+
let lastActivity = Date.now();
|
|
92
|
+
let resolved = false;
|
|
93
|
+
|
|
94
|
+
// Track activity
|
|
95
|
+
const onTool = () => { lastActivity = Date.now(); };
|
|
96
|
+
const onLlm = () => { lastActivity = Date.now(); };
|
|
97
|
+
bus.on('tool:called', onTool);
|
|
98
|
+
bus.on('llm:text', onLlm);
|
|
99
|
+
|
|
100
|
+
const cleanup = () => {
|
|
101
|
+
bus.off('tool:called', onTool);
|
|
102
|
+
bus.off('llm:text', onLlm);
|
|
103
|
+
if (check) clearInterval(check);
|
|
104
|
+
if (timer) clearTimeout(timer);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Timeout
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
if (!resolved) {
|
|
110
|
+
resolved = true;
|
|
111
|
+
cleanup();
|
|
112
|
+
const newCalls = this.toolCallCount - toolCallsBefore;
|
|
113
|
+
if (newCalls === 0 && maxRetries > 0) {
|
|
114
|
+
log('warn', '⏰ No response, retrying...');
|
|
115
|
+
cleanup();
|
|
116
|
+
resolve(this._retry(timeoutMs, maxRetries));
|
|
117
|
+
} else {
|
|
118
|
+
resolve({ timeout: true, toolCalls: newCalls });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
|
|
123
|
+
// Check if done (no new activity for 8s after tool calls)
|
|
124
|
+
const check = setInterval(() => {
|
|
125
|
+
if (resolved) return;
|
|
126
|
+
const elapsed = Date.now() - startTime;
|
|
127
|
+
const quietFor = Date.now() - lastActivity;
|
|
128
|
+
const newCalls = this.toolCallCount - toolCallsBefore;
|
|
129
|
+
|
|
130
|
+
// If we got tool calls and been quiet for 8s, we're done
|
|
131
|
+
if (newCalls > 0 && quietFor > 8000) {
|
|
132
|
+
resolved = true;
|
|
133
|
+
cleanup();
|
|
134
|
+
resolve({ success: true, toolCalls: newCalls, elapsed });
|
|
135
|
+
}
|
|
136
|
+
}, 1000);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async _retry(timeoutMs, retriesLeft) {
|
|
141
|
+
this.retryCount++;
|
|
142
|
+
log('info', `🔄 Retry ${this.retryCount}...`);
|
|
143
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
144
|
+
// Don't resend, just wait for response again
|
|
145
|
+
return this._waitForResponse(timeoutMs, retriesLeft - 1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MqttClient — XiaoZhi MQTT connection + MCP
|
|
3
|
+
* Uses MQTT library's built-in reconnect (reconnectPeriod: 5000)
|
|
4
|
+
*/
|
|
5
|
+
import mqtt from 'mqtt';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import { log, debug } from '../core/logger.js';
|
|
8
|
+
import { bus } from '../core/event-bus.js';
|
|
9
|
+
import { McpHandler } from './mcp-handler.js';
|
|
10
|
+
import { fetchOtaConfig, handleActivation } from './activation.js';
|
|
11
|
+
|
|
12
|
+
export class MqttClient {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.client = null;
|
|
16
|
+
this.connected = false;
|
|
17
|
+
this.sessionId = null;
|
|
18
|
+
this.deviceId = config.mqtt?.device_id || null;
|
|
19
|
+
this.clientId = config.mqtt?.client_id || null;
|
|
20
|
+
this.mqttCfg = null;
|
|
21
|
+
this.mcp = new McpHandler();
|
|
22
|
+
this._helloQueue = [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async connect() {
|
|
26
|
+
if (!this.deviceId) this.deviceId = this._randomMac();
|
|
27
|
+
if (!this.clientId) this.clientId = crypto.randomUUID();
|
|
28
|
+
await this._persistIds();
|
|
29
|
+
|
|
30
|
+
log('info', 'Fetching device config...');
|
|
31
|
+
const data = await fetchOtaConfig(this.config.mqtt.ota_url, this.deviceId, this.clientId);
|
|
32
|
+
this.mqttCfg = await handleActivation(data, this.config.mqtt.ota_url, this.deviceId, this.clientId);
|
|
33
|
+
log('info', `MQTT: ${this.mqttCfg.endpoint}`);
|
|
34
|
+
return this._connectMqtt();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async _persistIds() {
|
|
38
|
+
if (this.config.mqtt?.device_id) return;
|
|
39
|
+
this.config.mqtt = { ...this.config.mqtt, device_id: this.deviceId, client_id: this.clientId };
|
|
40
|
+
try {
|
|
41
|
+
const { saveConfig } = await import('../core/config.js');
|
|
42
|
+
await saveConfig(this.config._root, this.config);
|
|
43
|
+
log('info', 'Device ID saved');
|
|
44
|
+
} catch (e) { debug(`Config save: ${e.message}`); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_connectMqtt() {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
this.client = mqtt.connect(`mqtts://${this.mqttCfg.endpoint}:8883`, {
|
|
50
|
+
clientId: this.mqttCfg.client_id,
|
|
51
|
+
username: this.mqttCfg.username,
|
|
52
|
+
password: this.mqttCfg.password,
|
|
53
|
+
keepalive: 240,
|
|
54
|
+
reconnectPeriod: 5000,
|
|
55
|
+
connectTimeout: 10000,
|
|
56
|
+
clean: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.client.on('connect', () => {
|
|
60
|
+
this.connected = true;
|
|
61
|
+
log('ok', 'MQTT Connected');
|
|
62
|
+
this.client.subscribe('devices/p2p/#');
|
|
63
|
+
this._sendHello();
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.client.on('message', (_, raw) => this._onMessage(raw));
|
|
68
|
+
this.client.on('error', e => { log('err', `MQTT: ${e.message}`); bus.emit('mqtt:error', e); });
|
|
69
|
+
this.client.on('close', () => {
|
|
70
|
+
this.connected = false;
|
|
71
|
+
this.sessionId = null;
|
|
72
|
+
this.mcp.reset();
|
|
73
|
+
this._helloQueue = [];
|
|
74
|
+
log('warn', 'MQTT Disconnected');
|
|
75
|
+
});
|
|
76
|
+
this.client.on('reconnect', () => log('info', 'MQTT Reconnecting...'));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_sendHello() {
|
|
81
|
+
this.client.publish(this.mqttCfg.publish_topic, JSON.stringify({
|
|
82
|
+
type: 'hello', version: 3, transport: 'udp',
|
|
83
|
+
features: { mcp: true },
|
|
84
|
+
audio_params: { format: 'opus', sample_rate: 16000, channels: 1, frame_duration: 60 },
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_onMessage(raw) {
|
|
89
|
+
let msg;
|
|
90
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
91
|
+
if (msg.session_id) this.sessionId = msg.session_id;
|
|
92
|
+
|
|
93
|
+
if (msg.type === 'hello') {
|
|
94
|
+
if (!this.mcp.toolsReceived) { this._helloQueue.push(msg); return; }
|
|
95
|
+
this._onHello(msg);
|
|
96
|
+
} else if (msg.type === 'mcp') {
|
|
97
|
+
this._onMcp(msg);
|
|
98
|
+
} else {
|
|
99
|
+
this._onOther(msg);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_onHello(msg) {
|
|
104
|
+
this.sessionId = msg.session_id;
|
|
105
|
+
log('ok', `Session: ${this.sessionId}`);
|
|
106
|
+
bus.emit('ready');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_onMcp(msg) {
|
|
110
|
+
const responses = this.mcp.handleMessage(msg);
|
|
111
|
+
for (const r of responses) {
|
|
112
|
+
if (r.type === 'mcp') { this._send(r); }
|
|
113
|
+
else if (r.type === 'tool_call') {
|
|
114
|
+
log('tool', `🔧 ${r.name}`);
|
|
115
|
+
this.mcp.executeTool(r.name, r.args)
|
|
116
|
+
.then(result => { log('tool', result.isError ? '❌' : '✅'); 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 } } }); });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (this.mcp.toolsReceived && this._helloQueue.length) {
|
|
121
|
+
for (const h of this._helloQueue) this._onHello(h);
|
|
122
|
+
this._helloQueue = [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_onOther(msg) {
|
|
127
|
+
if (msg.type === 'stt') { log('user', `🎤 "${msg.text}"`); bus.emit('stt', msg.text); }
|
|
128
|
+
if (msg.type === 'llm' && msg.text) { bus.emit('llm:text', msg.text); }
|
|
129
|
+
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 = []; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_send(msg) {
|
|
134
|
+
if (!this.client || !this.mqttCfg) return;
|
|
135
|
+
if (this.sessionId) msg.session_id = this.sessionId;
|
|
136
|
+
this.client.publish(this.mqttCfg.publish_topic, JSON.stringify(msg));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
sendText(text) {
|
|
140
|
+
this._send({ type: 'listen', state: 'detect', text });
|
|
141
|
+
log('user', `💬 "${text}"`);
|
|
142
|
+
bus.emit('user:text', text);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
abort() { this._send({ type: 'abort' }); }
|
|
146
|
+
|
|
147
|
+
registerToolHandler(handler, toolsFn) {
|
|
148
|
+
this.mcp.setToolCaller(handler);
|
|
149
|
+
this.mcp.setToolProvider(toolsFn);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_randomMac() {
|
|
153
|
+
return Array.from(crypto.randomBytes(6)).map(b => b.toString(16).padStart(2, '0')).join(':').toUpperCase();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
disconnect() {
|
|
157
|
+
if (this.client) { this.client.end(true); this.client = null; }
|
|
158
|
+
this.connected = false;
|
|
159
|
+
}
|
|
160
|
+
}
|