pelulu-cli 1.0.5 → 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.
- package/CHANGELOG.md +81 -0
- package/docs/AGENT.md +237 -0
- package/package.json +3 -2
- package/specifications/xiaozhi-mqtt-broker.md +46 -0
- package/src/agent/agent-controller.js +100 -0
- package/src/agent/agent-loop.js +276 -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 +50 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- package/src/core/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +13 -5
- package/src/index.js +82 -22
- package/src/mcp/mcp-handler.js +29 -2
- package/src/mcp/mqtt-client.js +15 -3
- package/src/tools/agent.js +80 -0
- package/src/tui/completable-input.js +49 -42
- package/src/tui/ink-app.js +91 -12
- package/src/tui/ink-components.js +58 -11
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
package/src/core/logger.js
CHANGED
|
@@ -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
|
-
*
|
|
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 ${
|
|
10
|
-
`You have access to ${tools.length}
|
|
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
|
-
|
|
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,
|
|
25
|
-
lines.push('
|
|
26
|
-
lines.push('
|
|
27
|
-
lines.push('
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 = {}) {
|
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
|
-
//
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
172
|
-
bus.on('user:text', (text) =>
|
|
173
|
-
|
|
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
|
-
//
|
|
230
|
+
// 13. Save config
|
|
176
231
|
await saveConfig(ROOT, config);
|
|
177
232
|
|
|
178
|
-
//
|
|
233
|
+
// 14. Start Ink TUI
|
|
179
234
|
const { unmount, waitUntilExit } = startInkTUI({
|
|
180
235
|
registry, mqtt, stats, session, bus, config,
|
|
181
|
-
extras: {
|
|
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);
|
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: '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
|
}
|
package/src/mcp/mqtt-client.js
CHANGED
|
@@ -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:
|
|
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 => {
|
|
117
|
-
|
|
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) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Tool — Expose agent capabilities (1 MCP tool, 5 actions)
|
|
3
|
+
* Actions: run, status, abort, reset, context
|
|
4
|
+
*/
|
|
5
|
+
import { bus } from '../core/event-bus.js';
|
|
6
|
+
|
|
7
|
+
let agentController = null;
|
|
8
|
+
|
|
9
|
+
export function setAgentController(controller) {
|
|
10
|
+
agentController = controller;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ACTIONS = {
|
|
14
|
+
run: {
|
|
15
|
+
required: ['task'],
|
|
16
|
+
handler: async ({ task }) => {
|
|
17
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
18
|
+
const result = await agentController.run(task, { generatePlan: false });
|
|
19
|
+
return { success: result.success, result: result.result, iterations: result.iterations };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
status: {
|
|
24
|
+
required: [],
|
|
25
|
+
handler: async () => {
|
|
26
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
27
|
+
return agentController.summary;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
abort: {
|
|
32
|
+
required: [],
|
|
33
|
+
handler: async () => {
|
|
34
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
35
|
+
agentController.abort();
|
|
36
|
+
return { aborted: true };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
reset: {
|
|
41
|
+
required: [],
|
|
42
|
+
handler: async () => {
|
|
43
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
44
|
+
agentController.reset();
|
|
45
|
+
return { reset: true };
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
context: {
|
|
50
|
+
required: [],
|
|
51
|
+
handler: async () => {
|
|
52
|
+
if (!agentController) throw new Error('Agent not initialized');
|
|
53
|
+
return { context: await agentController.getContext() };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const actionNames = Object.keys(ACTIONS);
|
|
59
|
+
|
|
60
|
+
export default {
|
|
61
|
+
name: 'agent',
|
|
62
|
+
description: 'Agent: run task, status, abort, reset, context',
|
|
63
|
+
actions: actionNames.map(name => ({ name, required: ACTIONS[name].required })),
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
action: { type: 'string' },
|
|
68
|
+
task: { type: 'string' },
|
|
69
|
+
},
|
|
70
|
+
required: ['action'],
|
|
71
|
+
},
|
|
72
|
+
async handler({ action, ...params }) {
|
|
73
|
+
const a = ACTIONS[action];
|
|
74
|
+
if (!a) throw new Error(`Unknown: ${action}. Use: ${actionNames.join(', ')}`);
|
|
75
|
+
for (const f of a.required) {
|
|
76
|
+
if (params[f] === undefined) throw new Error(`Missing: ${f}`);
|
|
77
|
+
}
|
|
78
|
+
return a.handler(params);
|
|
79
|
+
},
|
|
80
|
+
};
|