pelulu-cli 1.0.3 → 1.1.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.
@@ -1,10 +1,18 @@
1
1
  /**
2
- * Logger — colored terminal output with levels
2
+ * Logger — colored terminal output with levels + file logging
3
3
  * Supports Ink mode: when active, logs go through bus instead of console
4
4
  */
5
+ import { writeFile, mkdir, appendFile, readdir, unlink } from 'fs/promises';
6
+ import { existsSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { homedir } from 'os';
9
+
5
10
  let _debug = false;
6
11
  let _inkMode = false;
7
12
  let _bus = null;
13
+ let _logFile = null;
14
+ let _logQueue = [];
15
+ let _logTimer = null;
8
16
 
9
17
  export const COLORS = {
10
18
  reset: '\x1b[0m',
@@ -35,9 +43,82 @@ export function setDebug(enabled) { _debug = enabled; }
35
43
  export function isDebug() { return _debug; }
36
44
  export function debug(msg, data) { log('debug', msg, data); }
37
45
 
46
+ /**
47
+ * Initialize file logging
48
+ * Deletes old logs, keeps only the latest one
49
+ */
50
+ export async function initFileLog(root, appName = 'pelulu') {
51
+ const logDir = join(root, 'logs');
52
+ await mkdir(logDir, { recursive: true });
53
+
54
+ // Delete old log files
55
+ try {
56
+ const files = await readdir(logDir);
57
+ for (const f of files) {
58
+ if (f.endsWith('.log')) {
59
+ await unlink(join(logDir, f)).catch(() => {});
60
+ }
61
+ }
62
+ } catch {}
63
+
64
+ const now = new Date();
65
+ const date = now.toISOString().slice(0, 10);
66
+ const time = now.toISOString().slice(11, 19).replace(/:/g, '-');
67
+ _logFile = join(logDir, `${appName}-${date}_${time}.log`);
68
+
69
+ // Write header
70
+ const header = `═══════════════════════════════════════\n` +
71
+ `${appName} Log\n` +
72
+ `Started: ${now.toISOString()}\n` +
73
+ `═══════════════════════════════════════\n\n`;
74
+ await writeFile(_logFile, header, 'utf-8');
75
+ return _logFile;
76
+ }
77
+
78
+ /**
79
+ * Write log entry to file
80
+ */
81
+ function writeToFile(level, msg, data) {
82
+ if (!_logFile) return;
83
+
84
+ const ts = new Date().toISOString().slice(11, 23);
85
+ const icon = ICONS[level] || '';
86
+ let line = `${ts} ${icon} ${msg}`;
87
+ if (data && _debug) line += ` ${JSON.stringify(data)}`;
88
+ line += '\n';
89
+
90
+ _logQueue.push(line);
91
+ _flushSoon();
92
+ }
93
+
94
+ /**
95
+ * Write raw text to log file (for AI responses, chat messages)
96
+ */
97
+ export function writeRawToLog(text) {
98
+ if (!_logFile || !text) return;
99
+ const ts = new Date().toISOString().slice(11, 23);
100
+ _logQueue.push(`${ts} ${text}\n`);
101
+ _flushSoon();
102
+ }
103
+
104
+ function _flushSoon() {
105
+ if (_logTimer) return;
106
+ _logTimer = setTimeout(async () => {
107
+ const batch = _logQueue.join('');
108
+ _logQueue = [];
109
+ _logTimer = null;
110
+ try {
111
+ await appendFile(_logFile, batch, 'utf-8');
112
+ } catch {}
113
+ }, 500);
114
+ }
115
+
38
116
  export function log(level, msg, data) {
39
117
  if (level === 'debug' && !_debug) return;
40
118
 
119
+ // Write to file
120
+ writeToFile(level, msg, data);
121
+
41
122
  // In Ink mode, route logs through bus so they render inside the TUI
42
123
  if (_inkMode && _bus) {
43
124
  _bus.emit('log:message', { level, msg, data });
@@ -56,6 +137,30 @@ export function log(level, msg, data) {
56
137
  if (data && _debug) console.log(`${COLORS.gray}${JSON.stringify(data, null, 2)}${COLORS.reset}`);
57
138
  }
58
139
 
140
+ /**
141
+ * Flush pending logs to file
142
+ */
143
+ export async function flushLogs() {
144
+ if (_logTimer) {
145
+ clearTimeout(_logTimer);
146
+ _logTimer = null;
147
+ }
148
+ if (_logQueue.length > 0 && _logFile) {
149
+ const batch = _logQueue.join('');
150
+ _logQueue = [];
151
+ try {
152
+ await appendFile(_logFile, batch, 'utf-8');
153
+ } catch {}
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Get log file path
159
+ */
160
+ export function getLogFile() {
161
+ return _logFile;
162
+ }
163
+
59
164
  export function table(rows) {
60
165
  if (!rows.length) return;
61
166
  const keys = Object.keys(rows[0]);
@@ -1,13 +1,22 @@
1
1
  /**
2
2
  * SystemPrompt — builds context prompt for XiaoZhi LLM
3
- * Tells the AI what tools are available and how to use them
3
+ *
4
+ * Now supports both legacy mode and OpenHands-style agent mode.
5
+ * The agent mode provides richer context and tool descriptions.
4
6
  */
5
7
 
8
+ // Re-export from agent module for backward compatibility
9
+ export { buildSystemPrompt as buildAgentSystemPrompt } from '../agent/system-prompt.js';
10
+
11
+ /**
12
+ * Legacy system prompt (for direct XiaoZhi MQTT mode)
13
+ */
6
14
  export function buildSystemPrompt(registry, config) {
7
15
  const tools = registry.list();
16
+ const agentName = config.agent?.name || 'Pelulu';
8
17
  const lines = [
9
- `You are ${config.agent?.name || 'a coding agent'} running in Termux/Node.js.`,
10
- `You have access to ${tools.length} MCP tools. Use them to help the user.`,
18
+ `You are ${agentName}, an AI coding agent running in Termux/Node.js.`,
19
+ `You have access to ${tools.length} tools. Use them to help the user.`,
11
20
  '',
12
21
  '## Available Tools',
13
22
  '',
@@ -16,15 +25,21 @@ export function buildSystemPrompt(registry, config) {
16
25
  for (const tool of tools) {
17
26
  lines.push(`### ${tool.name}`);
18
27
  lines.push(`${tool.description}`);
19
- lines.push(`Actions: ${tool.actions.join(', ')}`);
28
+ if (tool.actions && tool.actions.length > 0) {
29
+ lines.push(`Actions: ${tool.actions.map(a => typeof a === 'string' ? a : a.name).join(', ')}`);
30
+ }
20
31
  lines.push('');
21
32
  }
22
33
 
23
34
  lines.push('## Tool Call Format');
24
- lines.push('When you need to use a tool, call it with the action and required parameters.');
25
- lines.push('Example: file(action="read", path="./src/index.js")');
26
- lines.push('Example: shell(action="exec", command="npm test")');
27
- lines.push('Example: git(action="status")');
35
+ lines.push('When you need to use a tool, respond with a JSON object:');
36
+ lines.push('```json');
37
+ lines.push('{"tool": "tool_name", "action": "action_name", "param1": "value1", ...}');
38
+ lines.push('```');
39
+ lines.push('');
40
+ lines.push('Example: {"tool": "file", "action": "read", "path": "./src/index.js"}');
41
+ lines.push('Example: {"tool": "shell", "action": "exec", "command": "npm test"}');
42
+ lines.push('Example: {"tool": "git", "action": "status"}');
28
43
  lines.push('');
29
44
 
30
45
  lines.push('## Guidelines');
@@ -33,6 +48,7 @@ export function buildSystemPrompt(registry, config) {
33
48
  lines.push('- Run tests after making changes');
34
49
  lines.push('- Be concise in explanations');
35
50
  lines.push('- Show code changes clearly');
51
+ lines.push('- When done, call: {"tool": "finish", "result": "summary of what was done"}');
36
52
 
37
53
  return lines.join('\n');
38
54
  }
@@ -51,11 +51,19 @@ export class ToolRegistry {
51
51
  }
52
52
 
53
53
  toMcpTools() {
54
- return this.all().map(t => ({
55
- name: t.name,
56
- description: t.description,
57
- inputSchema: t.inputSchema || { type: 'object', properties: {} },
58
- }));
54
+ return this.all().map(t => {
55
+ const schema = t.inputSchema || { type: 'object', properties: {} };
56
+ const compact = { type: 'object', properties: {} };
57
+ // Send all properties with type + enum only (no descriptions)
58
+ if (schema.properties) {
59
+ for (const [k, v] of Object.entries(schema.properties)) {
60
+ const prop = { type: v.type };
61
+ if (v.enum) prop.enum = v.enum;
62
+ compact.properties[k] = prop;
63
+ }
64
+ }
65
+ return { name: t.name, description: t.description, inputSchema: compact };
66
+ });
59
67
  }
60
68
 
61
69
  async call(name, args = {}) {
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Update Checker — compare local version with GitHub releases
3
- * Uses: https://api.github.com/repos/venenapro/Pelulu-CLI/releases
2
+ * Update Checker — compare local version with npm registry
3
+ * Uses: https://registry.npmjs.org/PACKAGE_NAME/latest
4
4
  */
5
5
  import { readFile } from 'fs/promises';
6
6
  import { join } from 'path';
7
7
 
8
- const GITHUB_RELEASES_URL = 'https://api.github.com/repos/venenapro/Pelulu-CLI/releases';
8
+ const NPM_REGISTRY_URL = 'https://registry.npmjs.org';
9
9
  const TIMEOUT_MS = 8000;
10
10
 
11
11
  /**
@@ -43,36 +43,41 @@ async function getLocalVersion(root) {
43
43
  }
44
44
 
45
45
  /**
46
- * Fetch latest release from GitHub API.
47
- * Returns { tag, name, body, url, publishedAt } or null on failure.
46
+ * Read package name from package.json
48
47
  */
49
- async function fetchLatestRelease() {
48
+ async function getPackageName(root) {
49
+ try {
50
+ const raw = await readFile(join(root, 'package.json'), 'utf-8');
51
+ const pkg = JSON.parse(raw);
52
+ return pkg.name || 'pelulu-cli';
53
+ } catch {
54
+ return 'pelulu-cli';
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Fetch latest version from npm registry.
60
+ * Returns { version, url } or null on failure.
61
+ */
62
+ async function fetchLatestFromNpm(packageName) {
50
63
  const controller = new AbortController();
51
64
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
52
65
 
53
66
  try {
54
- const res = await fetch(GITHUB_RELEASES_URL, {
67
+ const res = await fetch(`${NPM_REGISTRY_URL}/${packageName}/latest`, {
55
68
  signal: controller.signal,
56
- headers: {
57
- 'Accept': 'application/vnd.github+json',
58
- 'User-Agent': 'Pelulu-CLI',
59
- },
69
+ headers: { 'Accept': 'application/json' },
60
70
  });
61
71
 
62
72
  if (!res.ok) return null;
63
73
 
64
- const releases = await res.json();
65
- if (!Array.isArray(releases) || releases.length === 0) return null;
66
-
67
- // Filter out prerelease/draft, pick latest stable
68
- const stable = releases.find(r => !r.prerelease && !r.draft) || releases[0];
74
+ const data = await res.json();
75
+ const version = data.version;
76
+ if (!version) return null;
69
77
 
70
78
  return {
71
- tag: stable.tag_name || '',
72
- name: stable.name || stable.tag_name || '',
73
- body: stable.body || '',
74
- url: stable.html_url || '',
75
- publishedAt: stable.published_at || '',
79
+ version,
80
+ url: `https://www.npmjs.com/package/${packageName}`,
76
81
  };
77
82
  } catch {
78
83
  return null;
@@ -82,9 +87,9 @@ async function fetchLatestRelease() {
82
87
  }
83
88
 
84
89
  /**
85
- * Check for updates.
90
+ * Check for updates (from npm registry).
86
91
  * Returns:
87
- * { available: true, local, remote, release } — update available
92
+ * { available: true, local, remote, npm_url } — update available
88
93
  * { available: false, local, remote } — up to date
89
94
  * { available: false, error: true, message } — check failed
90
95
  */
@@ -94,16 +99,17 @@ export async function checkForUpdates(root) {
94
99
  return { available: false, error: true, message: 'Could not read local version from package.json' };
95
100
  }
96
101
 
97
- const release = await fetchLatestRelease();
98
- if (!release) {
99
- return { available: false, error: true, local: localVersion, message: 'Could not fetch GitHub releases' };
102
+ const packageName = await getPackageName(root);
103
+ const npm = await fetchLatestFromNpm(packageName);
104
+ if (!npm) {
105
+ return { available: false, error: true, local: localVersion, message: 'Could not fetch npm registry' };
100
106
  }
101
107
 
102
108
  const localParsed = parseSemver(localVersion);
103
- const remoteParsed = parseSemver(release.tag);
109
+ const remoteParsed = parseSemver(npm.version);
104
110
 
105
111
  if (!localParsed || !remoteParsed) {
106
- return { available: false, error: true, local: localVersion, remote: release.tag, message: 'Invalid version format' };
112
+ return { available: false, error: true, local: localVersion, remote: npm.version, message: 'Invalid version format' };
107
113
  }
108
114
 
109
115
  const cmp = compareSemver(remoteParsed, localParsed);
@@ -112,14 +118,14 @@ export async function checkForUpdates(root) {
112
118
  return {
113
119
  available: true,
114
120
  local: localVersion,
115
- remote: release.tag.replace(/^v/i, ''),
116
- release,
121
+ remote: npm.version,
122
+ release: { url: npm.url },
117
123
  };
118
124
  }
119
125
 
120
126
  return {
121
127
  available: false,
122
128
  local: localVersion,
123
- remote: release.tag.replace(/^v/i, ''),
129
+ remote: npm.version,
124
130
  };
125
131
  }
package/src/index.js CHANGED
@@ -2,18 +2,24 @@
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
16
  import { loadConfig, saveConfig } from './core/config.js';
10
- import { log, setDebug } from './core/logger.js';
17
+ import { log, setDebug, debug, setInkMode, initFileLog, flushLogs, getLogFile, writeRawToLog } from './core/logger.js';
11
18
  import { bus } from './core/event-bus.js';
12
19
  import { ToolRegistry } from './core/tool-registry.js';
13
20
  import { Sandbox } from './core/sandbox.js';
14
21
  import { SessionState } from './core/session.js';
15
22
  import { Stats } from './core/stats.js';
16
- import { buildSystemPrompt } from './core/system-prompt.js';
17
23
  import { buildContext } from './core/context.js';
18
24
  import { isDestructive, askConfirmation } from './core/confirm.js';
19
25
  import { runWizard } from './core/wizard.js';
@@ -28,15 +34,19 @@ import { MessageSender } from './mcp/message-sender.js';
28
34
  import { WssEndpoint } from './mcp/wss-endpoint.js';
29
35
  import { PluginManager } from './plugins/manager.js';
30
36
  import { checkForUpdates } from './core/update-checker.js';
31
- import { renderUpdateNotification, renderAsciiBanner } from './tui/renderer.js';
37
+ import { renderUpdateNotification, renderPostUpdate, renderAsciiBanner } from './tui/renderer.js';
32
38
  import { startInkTUI } from './tui/ink-entry.js';
33
39
 
40
+ // Import new agent system
41
+ import { AgentController } from './agent/agent-controller.js';
42
+
34
43
  const __dirname = dirname(fileURLToPath(import.meta.url));
35
44
  const ROOT = join(__dirname, '..');
36
45
 
37
46
  const args = process.argv.slice(2);
38
47
  if (args.includes('--debug')) setDebug(true);
39
48
  const LIST_TOOLS = args.includes('--list-tools');
49
+ const NO_AGENT = args.includes('--no-agent');
40
50
 
41
51
  async function main() {
42
52
  // Buffer ALL startup logs for Ink to display inside TUI
@@ -47,11 +57,32 @@ async function main() {
47
57
  // 1. Load config
48
58
  const config = await loadConfig(ROOT);
49
59
 
50
- // 2. Check for updates block if outdated
60
+ // 1b. Initialize file logging (deletes old logs, keeps only latest)
61
+ const appName = config.agent?.name?.toLowerCase().replace(/\s+/g, '-') || 'pelulu';
62
+ const logFile = await initFileLog(ROOT, appName);
63
+ debug('init', `Log file: ${logFile}`);
64
+
65
+ // 1c. Enable Ink mode early — route ALL logs through bus (no console.log leak)
66
+ if (process.stdin.isTTY) {
67
+ setInkMode(true, bus);
68
+ }
69
+
70
+ // 2. Auto-update — silently install latest if outdated
51
71
  const update = await checkForUpdates(ROOT);
52
72
  if (update.available) {
53
- renderUpdateNotification(update);
54
- process.exit(1);
73
+ const pkgName = await (async () => {
74
+ try { return JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf-8')).name; } catch { return 'pelulu-cli'; }
75
+ })();
76
+ try {
77
+ const { execSync } = await import('child_process');
78
+ execSync(`npm install -g ${pkgName}@latest`, { stdio: 'ignore' });
79
+ renderPostUpdate(pkgName, update.remote);
80
+ process.exit(0);
81
+ } catch (e) {
82
+ console.error(chalk.red(` ✗ Update failed: ${e.message}`));
83
+ console.log(chalk.gray(` Run manually: npm install -g ${pkgName}@latest`));
84
+ process.exit(1);
85
+ }
55
86
  }
56
87
 
57
88
  // 3. Workspace & wizard
@@ -61,8 +92,11 @@ async function main() {
61
92
  await runWizard(ROOT);
62
93
  }
63
94
 
64
- // Show ASCII banner before Ink takes over
65
- renderAsciiBanner(config.agent?.version);
95
+ // Banner is now a React component inside Ink TUI (AsciiBanner)
96
+ // Only render console banner for fallback REPL (non-TTY)
97
+ if (!process.stdin.isTTY) {
98
+ await renderAsciiBanner();
99
+ }
66
100
 
67
101
  // Redirect console.log to buffer — everything after this goes into Ink
68
102
  console.log = bufferLog;
@@ -95,7 +129,6 @@ async function main() {
95
129
 
96
130
  // 6. Build context & system prompt
97
131
  const context = await buildContext();
98
- const systemPrompt = buildSystemPrompt(registry, config);
99
132
 
100
133
  // 7. Connect to XiaoZhi
101
134
  const mqtt = new MqttClient(config);
@@ -125,14 +158,27 @@ async function main() {
125
158
  // 8. Message sender
126
159
  const sender = new MessageSender(mqtt);
127
160
 
128
- // 9. Register MCP tool handler
161
+ // 9. Initialize Agent Controller (OpenHands-style)
162
+ let agentController = null;
163
+ if (!NO_AGENT) {
164
+ agentController = new AgentController({
165
+ registry,
166
+ mqtt,
167
+ sandbox,
168
+ confirm: { isDestructive, ask: askConfirmation },
169
+ config,
170
+ });
171
+
172
+ startupLogs.push(chalk.green(' ✓ Agent system initialized'));
173
+ startupLogs.push(chalk.gray(` - Max iterations: ${config.agent?.max_iterations || 50}`));
174
+ startupLogs.push(chalk.gray(` - Max input: 70 chars`));
175
+ startupLogs.push(chalk.gray(` - Log: ${getLogFile()}`));
176
+ }
177
+
178
+ // 10. Register MCP tool handler (for XiaoZhi direct tool calls)
179
+ // Agent calls are auto-approved — XiaoZhi controls the flow, no y/N prompts
129
180
  mqtt.registerToolHandler(
130
181
  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
182
  sandbox.validate(name, args);
137
183
 
138
184
  thinking.set('tool_call');
@@ -158,7 +204,7 @@ async function main() {
158
204
  () => registry.toMcpTools()
159
205
  );
160
206
 
161
- // 10. Optional WSS endpoint
207
+ // 11. Optional WSS endpoint
162
208
  let wss = null;
163
209
  if (config.mcp?.endpoint_url) {
164
210
  wss = new WssEndpoint(config.mcp.endpoint_url, () => registry.toMcpTools(), async (name, args) => {
@@ -168,25 +214,39 @@ async function main() {
168
214
  wss.start();
169
215
  }
170
216
 
171
- // 11. Track conversation
172
- bus.on('user:text', (text) => session.addUserMessage(text));
173
- bus.on('llm:text', (text) => session.addAiMessage(text));
217
+ // 12. Track conversation + log AI responses
218
+ bus.on('user:text', (text) => {
219
+ session.addUserMessage(text);
220
+ writeRawToLog(`[USER] ${text}`);
221
+ });
222
+ bus.on('llm:text', (text) => {
223
+ session.addAiMessage(text);
224
+ writeRawToLog(`[AI] ${text}`);
225
+ });
226
+ bus.on('tts:sentence', (text) => {
227
+ writeRawToLog(`[AI-TTS] ${text}`);
228
+ });
174
229
 
175
- // 12. Save config
230
+ // 13. Save config
176
231
  await saveConfig(ROOT, config);
177
232
 
178
- // 13. Start Ink TUI
233
+ // 14. Start Ink TUI
179
234
  const { unmount, waitUntilExit } = startInkTUI({
180
235
  registry, mqtt, stats, session, bus, config,
181
- extras: { fileTracker, thinking, sender, autoFormat, startupLogs },
236
+ extras: {
237
+ fileTracker, thinking, sender, autoFormat, startupLogs,
238
+ agentController, // Pass agent controller to TUI
239
+ },
182
240
  });
183
241
 
184
242
  // Graceful shutdown
185
243
  const shutdown = async () => {
186
244
  unmount();
245
+ if (agentController) agentController.abort();
187
246
  if (wss) wss.stop();
188
247
  await registry.shutdown();
189
248
  mqtt.disconnect();
249
+ await flushLogs();
190
250
  process.exit(0);
191
251
  };
192
252
  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: 'shellulu', version: '1.0.0' },
47
47
  },
48
48
  },
49
49
  });
@@ -61,7 +61,7 @@ export class McpHandler {
61
61
  const 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
66
  responses.push({ type: 'mcp', payload: { jsonrpc: '2.0', id: p.id, result: { tools } } });
67
67
  log('mcp', `Sent ${tools.length} tools`);
@@ -89,4 +89,31 @@ export class McpHandler {
89
89
  _sanitize(name) {
90
90
  return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
91
91
  }
92
+
93
+ /**
94
+ * Optimize tool definition to stay under 8KB MQTT broker limit
95
+ * - Keep descriptions (truncated to 200 chars)
96
+ * - Keep enum values (critical for XiaoZhi to know valid actions)
97
+ * - Keep required fields (critical for XiaoZhi to know what's mandatory)
98
+ */
99
+ _optimizeTool(tool) {
100
+ const optimized = {
101
+ name: tool.name,
102
+ description: tool.description?.slice(0, 100) || '',
103
+ inputSchema: { type: 'object', properties: {} }
104
+ };
105
+
106
+ // Keep first 4 properties only (to stay under 8KB MQTT limit)
107
+ // action + first 3 params — enough for XiaoZhi to understand the tool
108
+ if (tool.inputSchema?.properties) {
109
+ const entries = Object.entries(tool.inputSchema.properties);
110
+ for (const [key, val] of entries.slice(0, 4)) {
111
+ const prop = { type: val.type };
112
+ if (val.enum) prop.enum = val.enum;
113
+ optimized.inputSchema.properties[key] = prop;
114
+ }
115
+ }
116
+
117
+ return optimized;
118
+ }
92
119
  }
@@ -50,10 +50,11 @@ export class MqttClient {
50
50
  clientId: this.mqttCfg.client_id,
51
51
  username: this.mqttCfg.username,
52
52
  password: this.mqttCfg.password,
53
- keepalive: 240,
53
+ keepalive: 60,
54
54
  reconnectPeriod: 5000,
55
55
  connectTimeout: 10000,
56
56
  clean: true,
57
+ protocolVersion: 4, // MQTT v3.1.1
57
58
  });
58
59
 
59
60
  this.client.on('connect', () => {
@@ -82,6 +83,7 @@ export class MqttClient {
82
83
  type: 'hello', version: 3, transport: 'udp',
83
84
  features: { mcp: true },
84
85
  audio_params: { format: 'opus', sample_rate: 16000, channels: 1, frame_duration: 60 },
86
+ system_prompt: 'You are a coding assistant. When the user asks to create, write, or edit files, you MUST use the file tool with appropriate action (write/edit/mkdir). Always use tools to complete tasks. Do not just describe what to do — actually do it using the tools.',
85
87
  }));
86
88
  }
87
89
 
@@ -112,9 +114,19 @@ export class MqttClient {
112
114
  if (r.type === 'mcp') { this._send(r); }
113
115
  else if (r.type === 'tool_call') {
114
116
  log('tool', r.name);
117
+ // Emit tool call event so agent can track it
118
+ bus.emit('mcp:tool_call', { name: r.name, args: r.args, id: r.id });
115
119
  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 } } }); });
120
+ .then(result => {
121
+ log('tool', result.isError ? 'failed' : 'done');
122
+ this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } });
123
+ // Emit tool result event
124
+ bus.emit('mcp:tool_result', { name: r.name, args: r.args, result, id: r.id });
125
+ })
126
+ .catch(err => {
127
+ this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: err.message }], isError: true } } });
128
+ bus.emit('mcp:tool_result', { name: r.name, args: r.args, result: { isError: true, content: [{ type: 'text', text: err.message }] }, id: r.id });
129
+ });
118
130
  }
119
131
  }
120
132
  if (this.mcp.toolsReceived && this._helloQueue.length) {