pelulu-cli 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -5
- package/src/agent/agent-controller.js +6 -1
- package/src/agent/agent-loop.js +169 -197
- package/src/agent/llm-client.js +6 -12
- package/src/core/config.js +1 -1
- package/src/core/http-client.js +285 -0
- package/src/core/job-manager.js +192 -0
- package/src/core/tool-registry.js +53 -2
- package/src/index.js +69 -7
- package/src/mcp/mcp-handler.js +35 -3
- package/src/mcp/mqtt-client.js +184 -13
- package/src/tools/file.js +8 -2
- package/src/tools/git.js +56 -6
- package/src/tools/jobs.js +93 -0
- package/src/tools/network.js +56 -23
- package/src/tools/project.js +38 -4
- package/src/tools/search.js +10 -14
- package/src/tools/shell.js +70 -4
- package/src/tui/ink-app.js +282 -22
- package/src/tui/ink-components.js +41 -18
- package/src/tui/renderer.js +1 -1
- package/docs/AGENT.md +0 -237
- package/specifications/xiaozhi-mqtt-broker.md +0 -46
package/src/mcp/mcp-handler.js
CHANGED
|
@@ -43,7 +43,7 @@ export class McpHandler {
|
|
|
43
43
|
result: {
|
|
44
44
|
protocolVersion: PROTOCOL_VERSION,
|
|
45
45
|
capabilities: { tools: {} },
|
|
46
|
-
serverInfo: { name: '
|
|
46
|
+
serverInfo: { name: 'pelulu-cli', version: '1.0.0' },
|
|
47
47
|
},
|
|
48
48
|
},
|
|
49
49
|
});
|
|
@@ -58,13 +58,18 @@ export class McpHandler {
|
|
|
58
58
|
if (p.method === 'tools/list') {
|
|
59
59
|
this.#toolsReceived = true;
|
|
60
60
|
this.#nameMap.clear();
|
|
61
|
-
|
|
61
|
+
let tools = (this.#toolsFn?.() || []).map(t => {
|
|
62
62
|
const safeName = this._sanitize(t.name);
|
|
63
63
|
this.#nameMap.set(safeName, t.name);
|
|
64
64
|
return this._optimizeTool({ name: safeName, description: t.description, inputSchema: t.inputSchema || { type: 'object', properties: {} } });
|
|
65
65
|
});
|
|
66
|
+
// Hard guard: the XiaoZhi broker disconnects the client if the
|
|
67
|
+
// tools/list payload exceeds ~8KB. Progressively shrink until it fits so
|
|
68
|
+
// a newly added tool can never silently break the whole connection.
|
|
69
|
+
tools = this._fitUnderLimit(tools, p.id);
|
|
70
|
+
const bytes = Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', id: p.id, result: { tools } }));
|
|
66
71
|
responses.push({ type: 'mcp', payload: { jsonrpc: '2.0', id: p.id, result: { tools } } });
|
|
67
|
-
log('mcp', `Sent ${tools.length} tools`);
|
|
72
|
+
log('mcp', `Sent ${tools.length} tools (${bytes} bytes)`);
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
if (p.method === 'tools/call') {
|
|
@@ -90,6 +95,33 @@ export class McpHandler {
|
|
|
90
95
|
return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Keep the serialized tools/list under the broker's ~8KB limit.
|
|
100
|
+
* Step 1: trim descriptions. Step 2: drop schema properties down to just
|
|
101
|
+
* `action`. We stop as soon as we're safely under the limit.
|
|
102
|
+
*/
|
|
103
|
+
_fitUnderLimit(tools, id, limit = 7800) {
|
|
104
|
+
const size = (t) => Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', id, result: { tools: t } }));
|
|
105
|
+
if (size(tools) <= limit) return tools;
|
|
106
|
+
|
|
107
|
+
// Step 1: shorten descriptions to ~40 chars.
|
|
108
|
+
let trimmed = tools.map(t => ({ ...t, description: (t.description || '').slice(0, 40) }));
|
|
109
|
+
if (size(trimmed) <= limit) { log('warn', 'tools/list trimmed descriptions to fit 8KB'); return trimmed; }
|
|
110
|
+
|
|
111
|
+
// Step 2: reduce each schema to only the `action` property.
|
|
112
|
+
trimmed = trimmed.map(t => {
|
|
113
|
+
const props = t.inputSchema?.properties || {};
|
|
114
|
+
const minimal = props.action ? { action: props.action } : {};
|
|
115
|
+
return { ...t, description: (t.description || '').slice(0, 24), inputSchema: { type: 'object', properties: minimal } };
|
|
116
|
+
});
|
|
117
|
+
if (size(trimmed) <= limit) { log('warn', 'tools/list reduced schemas to fit 8KB'); return trimmed; }
|
|
118
|
+
|
|
119
|
+
// Step 3 (last resort): drop tools from the end until it fits.
|
|
120
|
+
while (trimmed.length > 1 && size(trimmed) > limit) trimmed.pop();
|
|
121
|
+
log('warn', `tools/list truncated to ${trimmed.length} tools to fit 8KB`);
|
|
122
|
+
return trimmed;
|
|
123
|
+
}
|
|
124
|
+
|
|
93
125
|
/**
|
|
94
126
|
* Optimize tool definition to stay under 8KB MQTT broker limit
|
|
95
127
|
* - Keep descriptions (truncated to 200 chars)
|
package/src/mcp/mqtt-client.js
CHANGED
|
@@ -9,6 +9,13 @@ import { bus } from '../core/event-bus.js';
|
|
|
9
9
|
import { McpHandler } from './mcp-handler.js';
|
|
10
10
|
import { fetchOtaConfig, handleActivation } from './activation.js';
|
|
11
11
|
|
|
12
|
+
// Max serialized bytes for a single MQTT publish. The XiaoZhi broker drops the
|
|
13
|
+
// connection the instant a message crosses ~8KB (see specifications/
|
|
14
|
+
// xiaozhi-mqtt-broker.md). tools/list is already guarded; tool RESULTS must be
|
|
15
|
+
// clamped here too, otherwise a single large file.read / recursive list / shell
|
|
16
|
+
// dump silently kills the whole connection.
|
|
17
|
+
const MAX_MQTT_BYTES = 7800;
|
|
18
|
+
|
|
12
19
|
export class MqttClient {
|
|
13
20
|
constructor(config) {
|
|
14
21
|
this.config = config;
|
|
@@ -20,13 +27,38 @@ export class MqttClient {
|
|
|
20
27
|
this.mqttCfg = null;
|
|
21
28
|
this.mcp = new McpHandler();
|
|
22
29
|
this._helloQueue = [];
|
|
30
|
+
// Reconnection state — we manage reconnect ourselves because the broker
|
|
31
|
+
// hands out FRESH credentials from OTA on every connection (cached creds
|
|
32
|
+
// are rejected), so mqtt.js's built-in reconnect can never recover.
|
|
33
|
+
this._manualClose = false;
|
|
34
|
+
this._reconnectTimer = null;
|
|
35
|
+
this._reconnectAttempts = 0;
|
|
36
|
+
// Whether an agent turn is actively in progress. XiaoZhi closes idle audio
|
|
37
|
+
// sessions with a `goodbye`; if that lands while we're still working we must
|
|
38
|
+
// keep the session warm (re-`hello`) instead of letting the task stall until
|
|
39
|
+
// the next user message. Driven by the agent-loop's progress events.
|
|
40
|
+
this._turnActive = false;
|
|
41
|
+
this._resuming = false;
|
|
42
|
+
bus.on('agent:progress', ({ state } = {}) => {
|
|
43
|
+
if (state === 'done' || state === 'timeout') this._turnActive = false;
|
|
44
|
+
else if (state) this._turnActive = true; // thinking/tool/tool_done/receiving
|
|
45
|
+
});
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
async connect() {
|
|
26
49
|
if (!this.deviceId) this.deviceId = this._randomMac();
|
|
27
50
|
if (!this.clientId) this.clientId = crypto.randomUUID();
|
|
28
51
|
await this._persistIds();
|
|
52
|
+
this._manualClose = false;
|
|
53
|
+
return this._refreshAndConnect();
|
|
54
|
+
}
|
|
29
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Fetch fresh OTA credentials, then (re)connect. Used for the initial
|
|
58
|
+
* connection AND every reconnect — the broker requires new credentials
|
|
59
|
+
* each time, so we must always re-run the OTA handshake.
|
|
60
|
+
*/
|
|
61
|
+
async _refreshAndConnect() {
|
|
30
62
|
log('info', 'Fetching device config...');
|
|
31
63
|
const data = await fetchOtaConfig(this.config.mqtt.ota_url, this.deviceId, this.clientId);
|
|
32
64
|
this.mqttCfg = await handleActivation(data, this.config.mqtt.ota_url, this.deviceId, this.clientId);
|
|
@@ -46,44 +78,86 @@ export class MqttClient {
|
|
|
46
78
|
|
|
47
79
|
_connectMqtt() {
|
|
48
80
|
return new Promise((resolve, reject) => {
|
|
81
|
+
// Tear down any previous client so we never leak listeners / sockets.
|
|
82
|
+
if (this.client) {
|
|
83
|
+
try { this.client.removeAllListeners(); this.client.end(true); } catch {}
|
|
84
|
+
this.client = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
49
87
|
this.client = mqtt.connect(`mqtts://${this.mqttCfg.endpoint}:8883`, {
|
|
50
88
|
clientId: this.mqttCfg.client_id,
|
|
51
89
|
username: this.mqttCfg.username,
|
|
52
90
|
password: this.mqttCfg.password,
|
|
53
91
|
keepalive: 60,
|
|
54
|
-
reconnectPeriod:
|
|
55
|
-
connectTimeout:
|
|
92
|
+
reconnectPeriod: 0, // we manage reconnect ourselves (fresh OTA creds required)
|
|
93
|
+
connectTimeout: 15000,
|
|
56
94
|
clean: true,
|
|
57
95
|
protocolVersion: 4, // MQTT v3.1.1
|
|
58
96
|
});
|
|
59
97
|
|
|
98
|
+
let settled = false;
|
|
99
|
+
|
|
60
100
|
this.client.on('connect', () => {
|
|
61
101
|
this.connected = true;
|
|
102
|
+
this._reconnectAttempts = 0;
|
|
62
103
|
log('ok', 'MQTT Connected');
|
|
63
104
|
this.client.subscribe('devices/p2p/#');
|
|
64
105
|
this._sendHello();
|
|
65
|
-
|
|
106
|
+
bus.emit('mqtt:connected');
|
|
107
|
+
if (!settled) { settled = true; resolve(); }
|
|
66
108
|
});
|
|
67
109
|
|
|
68
110
|
this.client.on('message', (_, raw) => this._onMessage(raw));
|
|
69
111
|
this.client.on('error', e => { log('err', `MQTT: ${e.message}`); bus.emit('mqtt:error', e); });
|
|
70
112
|
this.client.on('close', () => {
|
|
113
|
+
const wasConnected = this.connected;
|
|
71
114
|
this.connected = false;
|
|
72
115
|
this.sessionId = null;
|
|
116
|
+
// A dropped MQTT connection invalidates the whole MCP handshake — the
|
|
117
|
+
// server re-runs initialize/tools/list on the next connection, so a
|
|
118
|
+
// full reset here is correct (unlike a `goodbye`, see _onOther).
|
|
73
119
|
this.mcp.reset();
|
|
74
120
|
this._helloQueue = [];
|
|
75
|
-
log('warn', 'MQTT Disconnected');
|
|
121
|
+
if (wasConnected) log('warn', 'MQTT Disconnected');
|
|
122
|
+
bus.emit('mqtt:disconnected');
|
|
123
|
+
// Kick off our own reconnect (fresh OTA credentials) unless the user
|
|
124
|
+
// asked to disconnect. This is what recovers from the sudden drops.
|
|
125
|
+
if (!this._manualClose) this._scheduleReconnect();
|
|
126
|
+
if (!settled) { settled = true; reject(new Error('MQTT connection closed before ready')); }
|
|
76
127
|
});
|
|
77
|
-
this.client.on('reconnect', () => log('info', 'MQTT Reconnecting...'));
|
|
78
128
|
});
|
|
79
129
|
}
|
|
80
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Reconnect with exponential backoff. Each attempt re-fetches OTA config so
|
|
133
|
+
* we always present valid, fresh credentials to the broker.
|
|
134
|
+
*/
|
|
135
|
+
_scheduleReconnect() {
|
|
136
|
+
if (this._reconnectTimer || this._manualClose) return;
|
|
137
|
+
this._reconnectAttempts++;
|
|
138
|
+
const delay = Math.min(30000, 2000 * Math.pow(1.6, Math.min(this._reconnectAttempts - 1, 6)));
|
|
139
|
+
log('info', `MQTT reconnecting in ${Math.round(delay / 1000)}s (attempt ${this._reconnectAttempts})...`);
|
|
140
|
+
bus.emit('mqtt:reconnecting', { attempt: this._reconnectAttempts, delay });
|
|
141
|
+
this._reconnectTimer = setTimeout(async () => {
|
|
142
|
+
this._reconnectTimer = null;
|
|
143
|
+
if (this._manualClose) return;
|
|
144
|
+
try {
|
|
145
|
+
await this._refreshAndConnect();
|
|
146
|
+
log('ok', 'MQTT reconnected');
|
|
147
|
+
bus.emit('mqtt:reconnected');
|
|
148
|
+
} catch (e) {
|
|
149
|
+
log('err', `Reconnect failed: ${e.message}`);
|
|
150
|
+
this._scheduleReconnect();
|
|
151
|
+
}
|
|
152
|
+
}, delay);
|
|
153
|
+
}
|
|
154
|
+
|
|
81
155
|
_sendHello() {
|
|
82
156
|
this.client.publish(this.mqttCfg.publish_topic, JSON.stringify({
|
|
83
157
|
type: 'hello', version: 3, transport: 'udp',
|
|
84
158
|
features: { mcp: true },
|
|
85
159
|
audio_params: { format: 'opus', sample_rate: 16000, channels: 1, frame_duration: 60 },
|
|
86
|
-
system_prompt: 'You are a coding
|
|
160
|
+
system_prompt: 'You are Pelulu, a CLI coding agent. When the user asks to create, write, or edit files, you MUST use the file tool (action write/edit/mkdir) — actually do it, never just describe it. For multi-file work, call the tools one after another until the whole task is done. When you finish, ALWAYS say one short sentence confirming what you did (e.g. "Done, created 3 files.") so the client knows the turn is complete. Keep spoken replies short.',
|
|
87
161
|
}));
|
|
88
162
|
}
|
|
89
163
|
|
|
@@ -119,13 +193,14 @@ export class MqttClient {
|
|
|
119
193
|
this.mcp.executeTool(r.name, r.args)
|
|
120
194
|
.then(result => {
|
|
121
195
|
log('tool', result.isError ? 'failed' : 'done');
|
|
122
|
-
this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } });
|
|
196
|
+
this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: this._clampResult(result) } });
|
|
123
197
|
// Emit tool result event
|
|
124
198
|
bus.emit('mcp:tool_result', { name: r.name, args: r.args, result, id: r.id });
|
|
125
199
|
})
|
|
126
200
|
.catch(err => {
|
|
127
|
-
|
|
128
|
-
|
|
201
|
+
const errResult = { content: [{ type: 'text', text: err.message }], isError: true };
|
|
202
|
+
this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: this._clampResult(errResult) } });
|
|
203
|
+
bus.emit('mcp:tool_result', { name: r.name, args: r.args, result: errResult, id: r.id });
|
|
129
204
|
});
|
|
130
205
|
}
|
|
131
206
|
}
|
|
@@ -139,19 +214,110 @@ export class MqttClient {
|
|
|
139
214
|
if (msg.type === 'stt') { log('user', `"${msg.text}"`); bus.emit('stt', msg.text); }
|
|
140
215
|
if (msg.type === 'llm' && msg.text) { bus.emit('llm:text', msg.text); }
|
|
141
216
|
if (msg.type === 'tts' && msg.state === 'sentence_start' && msg.text) { bus.emit('tts:sentence', msg.text); }
|
|
142
|
-
if (msg.type === 'goodbye') {
|
|
217
|
+
if (msg.type === 'goodbye') {
|
|
218
|
+
// A server `goodbye` only closes the audio SESSION — the MQTT connection
|
|
219
|
+
// and the MCP handshake (tools/list) remain valid. So we must NOT reset
|
|
220
|
+
// the MCP handler here; doing so made ensureSession() wait forever for a
|
|
221
|
+
// tools/list that never comes again, which looked like a permanent
|
|
222
|
+
// disconnect. We only drop the session id; the next message re-opens a
|
|
223
|
+
// session via a fresh hello (see ensureSession).
|
|
224
|
+
this.sessionId = null;
|
|
225
|
+
bus.emit('session:end', { turnActive: this._turnActive });
|
|
226
|
+
// Stay connected while there is still work in progress. If a turn is
|
|
227
|
+
// active we immediately re-open the session so the current task keeps
|
|
228
|
+
// talking to XiaoZhi, instead of stalling until the user types again.
|
|
229
|
+
// The MCP handshake is still valid (goodbye only closes the audio
|
|
230
|
+
// session), so this is a cheap `hello` round-trip, not a full reconnect.
|
|
231
|
+
if (this._turnActive && !this._manualClose && this.mcp.toolsReceived && !this._resuming) {
|
|
232
|
+
this._resuming = true;
|
|
233
|
+
this.ensureSession()
|
|
234
|
+
.then(ok => { if (ok) bus.emit('session:resumed'); })
|
|
235
|
+
.catch(() => {})
|
|
236
|
+
.finally(() => { this._resuming = false; });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Clamp an MCP tool-result so the serialized MQTT message stays under the
|
|
243
|
+
* broker's ~8KB limit. Oversized output (large file reads, recursive
|
|
244
|
+
* listings, shell dumps, search hits) would otherwise drop the whole
|
|
245
|
+
* connection the moment it's published. We truncate the result text and
|
|
246
|
+
* append a clear notice so the model knows the output was cut and can fetch
|
|
247
|
+
* the rest with a narrower request (e.g. offset/limit on file reads).
|
|
248
|
+
*/
|
|
249
|
+
_clampResult(result) {
|
|
250
|
+
const measure = (res) => Buffer.byteLength(JSON.stringify({
|
|
251
|
+
type: 'mcp', payload: { jsonrpc: '2.0', id: 0, result: res }, session_id: this.sessionId || '',
|
|
252
|
+
}));
|
|
253
|
+
try {
|
|
254
|
+
if (measure(result) <= MAX_MQTT_BYTES) return result;
|
|
255
|
+
|
|
256
|
+
const text = result?.content?.[0]?.text;
|
|
257
|
+
if (typeof text !== 'string') return result; // can't safely trim non-text
|
|
258
|
+
|
|
259
|
+
const notice = '\n\n[output truncated: exceeded the 8KB transport limit — request less at once (e.g. use offset/limit for file reads or a narrower query)]';
|
|
260
|
+
// Binary-search the largest text prefix that still fits once re-wrapped.
|
|
261
|
+
let lo = 0, hi = text.length, best = 0;
|
|
262
|
+
while (lo <= hi) {
|
|
263
|
+
const mid = (lo + hi) >> 1;
|
|
264
|
+
const candidate = { ...result, content: [{ type: 'text', text: text.slice(0, mid) + notice }] };
|
|
265
|
+
if (measure(candidate) <= MAX_MQTT_BYTES) { best = mid; lo = mid + 1; }
|
|
266
|
+
else { hi = mid - 1; }
|
|
267
|
+
}
|
|
268
|
+
log('warn', `Tool result truncated to ${best}/${text.length} chars to fit 8KB`);
|
|
269
|
+
return { ...result, content: [{ type: 'text', text: text.slice(0, best) + notice }] };
|
|
270
|
+
} catch { return result; }
|
|
143
271
|
}
|
|
144
272
|
|
|
145
273
|
_send(msg) {
|
|
146
274
|
if (!this.client || !this.mqttCfg) return;
|
|
147
275
|
if (this.sessionId) msg.session_id = this.sessionId;
|
|
148
|
-
|
|
276
|
+
const payload = JSON.stringify(msg);
|
|
277
|
+
// Final safety net: never publish a message that would trip the broker's
|
|
278
|
+
// ~8KB cutoff and drop the connection. Results are pre-clamped above; this
|
|
279
|
+
// catches any other oversized message type before it does damage.
|
|
280
|
+
if (Buffer.byteLength(payload) > MAX_MQTT_BYTES) {
|
|
281
|
+
log('warn', `Dropping oversized MQTT message (${Buffer.byteLength(payload)} bytes > ${MAX_MQTT_BYTES})`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.client.publish(this.mqttCfg.publish_topic, payload);
|
|
149
285
|
}
|
|
150
286
|
|
|
151
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Ensure a live XiaoZhi session exists before sending.
|
|
289
|
+
* XiaoZhi ends idle sessions with `goodbye` (common during long local
|
|
290
|
+
* tasks). When that happens we must re-send `hello` to get a fresh session
|
|
291
|
+
* + MCP handshake, otherwise all further messages are silently dropped.
|
|
292
|
+
* Resolves true once ready, false on timeout.
|
|
293
|
+
*/
|
|
294
|
+
async ensureSession(timeoutMs = 15000) {
|
|
295
|
+
if (this.sessionId && this.mcp.toolsReceived) return true;
|
|
296
|
+
if (!this.client) return false;
|
|
297
|
+
|
|
298
|
+
// Re-initiate the handshake (server replies with a new hello + session_id)
|
|
299
|
+
this._sendHello();
|
|
300
|
+
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
if (this.sessionId && this.mcp.toolsReceived) return resolve(true);
|
|
303
|
+
const onReady = () => { cleanup(); resolve(true); };
|
|
304
|
+
const poll = setInterval(() => {
|
|
305
|
+
if (this.sessionId && this.mcp.toolsReceived) { cleanup(); resolve(true); }
|
|
306
|
+
}, 200);
|
|
307
|
+
const timer = setTimeout(() => { cleanup(); resolve(false); }, timeoutMs);
|
|
308
|
+
const cleanup = () => { clearInterval(poll); clearTimeout(timer); bus.off('ready', onReady); };
|
|
309
|
+
bus.on('ready', onReady);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async sendText(text) {
|
|
314
|
+
// Re-establish the session if XiaoZhi dropped it while idle.
|
|
315
|
+
const ok = await this.ensureSession();
|
|
316
|
+
if (!ok) { bus.emit('session:dead'); return false; }
|
|
152
317
|
this._send({ type: 'listen', state: 'detect', text });
|
|
153
318
|
log('user', `"${text}"`);
|
|
154
319
|
bus.emit('user:text', text);
|
|
320
|
+
return true;
|
|
155
321
|
}
|
|
156
322
|
|
|
157
323
|
abort() { this._send({ type: 'abort' }); }
|
|
@@ -166,7 +332,12 @@ export class MqttClient {
|
|
|
166
332
|
}
|
|
167
333
|
|
|
168
334
|
disconnect() {
|
|
169
|
-
|
|
335
|
+
this._manualClose = true;
|
|
336
|
+
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
|
337
|
+
if (this.client) {
|
|
338
|
+
try { this.client.removeAllListeners(); this.client.end(true); } catch {}
|
|
339
|
+
this.client = null;
|
|
340
|
+
}
|
|
170
341
|
this.connected = false;
|
|
171
342
|
}
|
|
172
343
|
}
|
package/src/tools/file.js
CHANGED
|
@@ -46,13 +46,19 @@ const ACTIONS = {
|
|
|
46
46
|
},
|
|
47
47
|
|
|
48
48
|
edit: {
|
|
49
|
-
required:
|
|
49
|
+
// new_text is intentionally NOT required: XiaoZhi (a voice model) often
|
|
50
|
+
// omits it — either it means "delete this text" or it dropped the field
|
|
51
|
+
// mid-call. Treating a missing/undefined new_text as an empty string keeps
|
|
52
|
+
// the edit working (as a deletion) instead of failing the whole turn with
|
|
53
|
+
// "Missing required field: new_text".
|
|
54
|
+
required: ['path', 'old_text'],
|
|
50
55
|
handler: async ({ path, old_text, new_text }) => {
|
|
51
56
|
const abs = safe(path);
|
|
57
|
+
const replacement = new_text ?? '';
|
|
52
58
|
let content = await readFile(abs, 'utf-8');
|
|
53
59
|
if (!content.includes(old_text)) throw new Error(`old_text not found in ${abs}`);
|
|
54
60
|
const count = content.split(old_text).length - 1;
|
|
55
|
-
content = content.
|
|
61
|
+
content = content.split(old_text).join(replacement);
|
|
56
62
|
await writeFile(abs, content, 'utf-8');
|
|
57
63
|
log('file', `[EDIT] Edited: ${abs} (${count} occurrence(s))`);
|
|
58
64
|
return { path: abs, edited: true, occurrences: count };
|
package/src/tools/git.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Git Tool — consolidated git operations (1 MCP tool, 10 actions)
|
|
3
3
|
* Actions: init, clone, status, diff, log, add, commit, push, pull, branch
|
|
4
|
+
*
|
|
5
|
+
* Long-running operations (clone, push, pull) stream progress via task:progress
|
|
6
|
+
* so the TUI shows live feedback instead of appearing stuck.
|
|
4
7
|
*/
|
|
5
8
|
import { exec } from 'child_process';
|
|
6
9
|
import { log } from '../core/logger.js';
|
|
7
10
|
import { getConfig } from '../core/config.js';
|
|
11
|
+
import { bus } from '../core/event-bus.js';
|
|
8
12
|
|
|
9
|
-
function git(cmd, cwd) {
|
|
13
|
+
function git(cmd, cwd, { timeout = 60000 } = {}) {
|
|
10
14
|
return new Promise((resolve, reject) => {
|
|
11
|
-
exec(`git ${cmd}`, { cwd, timeout
|
|
15
|
+
exec(`git ${cmd}`, { cwd, timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
|
|
12
16
|
if (err?.killed) return reject(new Error('Git timed out'));
|
|
13
17
|
resolve({ stdout: stdout?.trim() || '', stderr: stderr?.trim() || '', code: err?.code ?? 0 });
|
|
14
18
|
});
|
|
@@ -17,6 +21,37 @@ function git(cmd, cwd) {
|
|
|
17
21
|
|
|
18
22
|
function cwd(params) { return params?.cwd || getConfig().agent?.workspace || process.cwd(); }
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Run a git command with progress reporting. For operations that may take
|
|
26
|
+
* a while (clone, push, pull), emits task:progress events.
|
|
27
|
+
*/
|
|
28
|
+
async function gitWithProgress(cmd, dir, { label, timeout = 120000 } = {}) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
const tag = label || `git ${cmd.split(' ')[0]}`;
|
|
31
|
+
|
|
32
|
+
bus.emit('task:progress', {
|
|
33
|
+
tool: 'git', running: true, phase: 'exec',
|
|
34
|
+
target: tag, elapsed: 0, log: `running: git ${cmd}`,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await git(cmd, dir, { timeout });
|
|
39
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
40
|
+
bus.emit('task:progress', {
|
|
41
|
+
tool: 'git', running: false, phase: 'done',
|
|
42
|
+
target: tag, elapsed, log: result.code === 0 ? 'completed' : `exit ${result.code}`,
|
|
43
|
+
});
|
|
44
|
+
return result;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
bus.emit('task:progress', {
|
|
47
|
+
tool: 'git', running: false, phase: 'error',
|
|
48
|
+
target: tag, elapsed: Math.round((Date.now() - started) / 1000),
|
|
49
|
+
log: err.message,
|
|
50
|
+
});
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
const ACTIONS = {
|
|
21
56
|
init: {
|
|
22
57
|
required: [],
|
|
@@ -31,7 +66,12 @@ const ACTIONS = {
|
|
|
31
66
|
required: ['url'],
|
|
32
67
|
handler: async ({ url, path, branch }) => {
|
|
33
68
|
const cmd = `clone${branch ? ` -b ${branch}` : ''} "${url}"${path ? ` "${path}"` : ''}`;
|
|
34
|
-
|
|
69
|
+
log('git', `[CLONE] ${url}`);
|
|
70
|
+
const result = await gitWithProgress(cmd, undefined, {
|
|
71
|
+
label: `clone ${url.split('/').pop()?.replace('.git', '') || url}`,
|
|
72
|
+
timeout: 120000,
|
|
73
|
+
});
|
|
74
|
+
if (result.code !== 0) throw new Error(result.stderr || 'clone failed');
|
|
35
75
|
return { cloned: true, url, path: path || url.split('/').pop()?.replace('.git', '') };
|
|
36
76
|
},
|
|
37
77
|
},
|
|
@@ -85,7 +125,7 @@ const ACTIONS = {
|
|
|
85
125
|
const msg = params.message.replace(/"/g, '\\"');
|
|
86
126
|
const r = await git(`commit -m "${msg}"`, dir);
|
|
87
127
|
if (r.code !== 0 && r.stderr.includes('nothing to commit')) return { committed: false, reason: 'nothing to commit' };
|
|
88
|
-
log('git', `[
|
|
128
|
+
log('git', `[COMMIT] ${params.message}`);
|
|
89
129
|
return { committed: true, message: params.message, output: r.stdout.slice(-300) };
|
|
90
130
|
},
|
|
91
131
|
},
|
|
@@ -96,7 +136,12 @@ const ACTIONS = {
|
|
|
96
136
|
const dir = cwd(params);
|
|
97
137
|
const remote = params.remote || '';
|
|
98
138
|
const branch = params.branch || '';
|
|
99
|
-
|
|
139
|
+
log('git', `[PUSH] ${remote} ${branch}`);
|
|
140
|
+
const r = await gitWithProgress(`push ${remote} ${branch}`.trim(), dir, {
|
|
141
|
+
label: `push ${remote || 'origin'} ${branch || 'current'}`,
|
|
142
|
+
timeout: 60000,
|
|
143
|
+
});
|
|
144
|
+
if (r.code !== 0) throw new Error(r.stderr || 'push failed');
|
|
100
145
|
return { pushed: true, output: r.stdout.slice(-300) || r.stderr.slice(-300) };
|
|
101
146
|
},
|
|
102
147
|
},
|
|
@@ -107,7 +152,12 @@ const ACTIONS = {
|
|
|
107
152
|
const dir = cwd(params);
|
|
108
153
|
const remote = params.remote || '';
|
|
109
154
|
const branch = params.branch || '';
|
|
110
|
-
|
|
155
|
+
log('git', `[PULL] ${remote} ${branch}`);
|
|
156
|
+
const r = await gitWithProgress(`pull ${remote} ${branch}`.trim(), dir, {
|
|
157
|
+
label: `pull ${remote || 'origin'} ${branch || 'current'}`,
|
|
158
|
+
timeout: 60000,
|
|
159
|
+
});
|
|
160
|
+
if (r.code !== 0) throw new Error(r.stderr || 'pull failed');
|
|
111
161
|
return { pulled: true, output: r.stdout.slice(-300) };
|
|
112
162
|
},
|
|
113
163
|
},
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobs — universal poll interface for background tool runs.
|
|
3
|
+
*
|
|
4
|
+
* Any tool action that takes longer than the grace window is turned into a
|
|
5
|
+
* background job (see core/job-manager.js). This tool is how the AI keeps
|
|
6
|
+
* track of them so it never assumes a still-running action "timed out":
|
|
7
|
+
*
|
|
8
|
+
* jobs { action: "list" } -> all recent jobs + their status
|
|
9
|
+
* jobs { action: "status", id: "job_x" } -> one job (or the latest if no id)
|
|
10
|
+
* jobs { action: "wait", id: "job_x" } -> wait briefly for it to finish
|
|
11
|
+
* jobs { action: "result", id: "job_x" } -> the finished job's result payload
|
|
12
|
+
* jobs { action: "cancel", id: "job_x" } -> request cancellation
|
|
13
|
+
*/
|
|
14
|
+
import { jobManager } from '../core/job-manager.js';
|
|
15
|
+
|
|
16
|
+
function describe(job) {
|
|
17
|
+
if (!job) return null;
|
|
18
|
+
const snap = jobManager.snapshot(job);
|
|
19
|
+
return {
|
|
20
|
+
...snap,
|
|
21
|
+
hint:
|
|
22
|
+
snap.status === 'running'
|
|
23
|
+
? `Still running (${snap.elapsed_s}s). Poll again with action=status id=${snap.id}, or action=wait.`
|
|
24
|
+
: snap.status === 'done'
|
|
25
|
+
? `Finished in ${snap.elapsed_s}s. Use action=result id=${snap.id} to read the output.`
|
|
26
|
+
: `${snap.status}.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ACTIONS = {
|
|
31
|
+
list: () => {
|
|
32
|
+
const jobs = jobManager.list().map(describe);
|
|
33
|
+
const running = jobs.filter((j) => j.status === 'running').length;
|
|
34
|
+
return {
|
|
35
|
+
total: jobs.length,
|
|
36
|
+
running,
|
|
37
|
+
message: running
|
|
38
|
+
? `${running} job(s) still running. Poll them with action=status.`
|
|
39
|
+
: 'No jobs are currently running.',
|
|
40
|
+
jobs: jobs.slice(-15),
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
status: ({ id }) => {
|
|
45
|
+
const job = id ? jobManager.get(id) : jobManager.latest();
|
|
46
|
+
if (!job) return { status: 'none', message: 'No jobs yet. Start a tool action first.' };
|
|
47
|
+
return describe(job);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
wait: async ({ id, timeout }) => {
|
|
51
|
+
const target = id ? jobManager.get(id) : jobManager.latest();
|
|
52
|
+
if (!target) return { status: 'none', message: 'No jobs to wait for.' };
|
|
53
|
+
const ms = Math.min(Math.max(Number(timeout) || 20000, 1000), 25000); // capped < XiaoZhi timeout
|
|
54
|
+
const job = await jobManager.wait(target.id, ms);
|
|
55
|
+
const out = describe(job);
|
|
56
|
+
if (job.status === 'running') out.message = `Still running after ${Math.round(ms / 1000)}s — poll again or action=wait.`;
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
result: ({ id }) => {
|
|
61
|
+
const job = id ? jobManager.get(id) : jobManager.latest();
|
|
62
|
+
if (!job) return { status: 'none', message: 'No jobs yet.' };
|
|
63
|
+
if (job.status === 'running') return { ...describe(job), message: 'Not finished yet — use action=wait first.' };
|
|
64
|
+
return { id: job.id, tool: job.tool, action: job.action, status: job.status, error: job.error || undefined, result: job.result };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
cancel: ({ id }) => {
|
|
68
|
+
const job = id ? jobManager.get(id) : jobManager.latest();
|
|
69
|
+
if (!job) return { status: 'none', message: 'No job to cancel.' };
|
|
70
|
+
const ok = jobManager.cancel(job.id);
|
|
71
|
+
return { id: job.id, cancelled: ok, message: ok ? 'Cancellation requested.' : `Job is already ${job.status}.` };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export default {
|
|
76
|
+
name: 'jobs',
|
|
77
|
+
description: 'Track background tool jobs so long-running actions never look timed out. Actions: list, status, wait, result, cancel.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
action: { type: 'string', enum: ['list', 'status', 'wait', 'result', 'cancel'] },
|
|
82
|
+
id: { type: 'string', description: 'Job id (defaults to the latest job)' },
|
|
83
|
+
timeout: { type: 'number', description: 'wait: max ms to block (<=25000)' },
|
|
84
|
+
},
|
|
85
|
+
required: ['action'],
|
|
86
|
+
},
|
|
87
|
+
async handler(args = {}) {
|
|
88
|
+
const action = args.action || 'status';
|
|
89
|
+
const fn = ACTIONS[action];
|
|
90
|
+
if (!fn) return { error: `Unknown action "${action}". Use: ${Object.keys(ACTIONS).join(', ')}` };
|
|
91
|
+
return await fn(args);
|
|
92
|
+
},
|
|
93
|
+
};
|