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.
Files changed (56) hide show
  1. package/README.md +273 -0
  2. package/config.example.json +24 -0
  3. package/package.json +44 -0
  4. package/src/core/auto-format.js +47 -0
  5. package/src/core/completion.js +65 -0
  6. package/src/core/config.js +68 -0
  7. package/src/core/confirm.js +51 -0
  8. package/src/core/context.js +69 -0
  9. package/src/core/conversation.js +57 -0
  10. package/src/core/diff.js +50 -0
  11. package/src/core/doctor.js +60 -0
  12. package/src/core/error-handler.js +39 -0
  13. package/src/core/event-bus.js +43 -0
  14. package/src/core/file-tracker.js +44 -0
  15. package/src/core/formatter.js +83 -0
  16. package/src/core/intent.js +81 -0
  17. package/src/core/keybindings.js +29 -0
  18. package/src/core/logger.js +67 -0
  19. package/src/core/model-info.js +26 -0
  20. package/src/core/retry.js +26 -0
  21. package/src/core/sandbox.js +54 -0
  22. package/src/core/session.js +46 -0
  23. package/src/core/spinner.js +60 -0
  24. package/src/core/stats.js +73 -0
  25. package/src/core/system-prompt.js +42 -0
  26. package/src/core/thinking.js +39 -0
  27. package/src/core/tool-help.js +117 -0
  28. package/src/core/tool-registry.js +82 -0
  29. package/src/core/wizard.js +55 -0
  30. package/src/core/workspace.js +96 -0
  31. package/src/index.js +150 -0
  32. package/src/mcp/activation.js +54 -0
  33. package/src/mcp/mcp-handler.js +92 -0
  34. package/src/mcp/message-sender.js +147 -0
  35. package/src/mcp/mqtt-client.js +160 -0
  36. package/src/mcp/wss-endpoint.js +133 -0
  37. package/src/plugins/manager.js +72 -0
  38. package/src/repl-commands.js +91 -0
  39. package/src/repl.js +115 -0
  40. package/src/tools/ai.js +128 -0
  41. package/src/tools/config.js +87 -0
  42. package/src/tools/diff.js +118 -0
  43. package/src/tools/env.js +58 -0
  44. package/src/tools/file.js +177 -0
  45. package/src/tools/git.js +166 -0
  46. package/src/tools/history.js +64 -0
  47. package/src/tools/network.js +87 -0
  48. package/src/tools/process.js +69 -0
  49. package/src/tools/project.js +152 -0
  50. package/src/tools/search.js +100 -0
  51. package/src/tools/shell.js +91 -0
  52. package/src/tools/snippet.js +95 -0
  53. package/src/tools/template.js +113 -0
  54. package/src/tools/watch.js +87 -0
  55. package/src/tui/renderer.js +119 -0
  56. 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
+ }