pelulu-cli 1.0.5 → 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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * JobManager — universal async job layer for ALL tools.
3
+ *
4
+ * WHY: XiaoZhi aborts any MCP tool call that runs longer than ~30s and then
5
+ * assumes the tool "timed out" — even though it is still working. That is the
6
+ * root cause of the "system timeout padahal berjalan" bug. Instead of making
7
+ * every one of the tools background-aware by hand, we wrap the tool dispatch
8
+ * boundary in a single job layer:
9
+ *
10
+ * - Fast actions (finish within `graceMs`) return their result inline, exactly
11
+ * as before — no behaviour change for reads/config/etc.
12
+ * - Slow actions keep running in the BACKGROUND and immediately return a
13
+ * job handle. The AI (or the user) then polls the `jobs` tool for progress
14
+ * and the final result, so the agent never stalls or hallucinates a failure.
15
+ *
16
+ * Every job streams lifecycle events on the bus (job:started / job:progress /
17
+ * job:done) so the TUI can show continuous, inline feedback for ANY tool.
18
+ */
19
+ import { randomUUID } from 'crypto';
20
+ import { bus } from './event-bus.js';
21
+ import { debug } from './logger.js';
22
+
23
+ const MAX_JOBS = 40; // ring-buffer cap so memory never grows unbounded
24
+ const DEFAULT_GRACE_MS = 8000; // < XiaoZhi's ~30s timeout, with wide margin
25
+
26
+ class JobManager {
27
+ #jobs = new Map(); // id -> job
28
+ #order = []; // insertion order for pruning + "latest"
29
+
30
+ /**
31
+ * Run `executor(job)` under a job. Resolves as soon as we know whether the
32
+ * work finished quickly or had to be backgrounded.
33
+ *
34
+ * @returns {Promise<{done:boolean, job:object, result?:any, error?:Error}>}
35
+ * done=true -> finished within the grace window (result/error populated)
36
+ * done=false -> still running in the background (poll via the `jobs` tool)
37
+ */
38
+ async dispatch({ tool, action, label } = {}, executor, { graceMs = DEFAULT_GRACE_MS } = {}) {
39
+ const job = this.#create({ tool, action, label });
40
+
41
+ // Kick off the actual work. We attach terminal handlers up-front so the job
42
+ // is always finalized regardless of whether we returned inline or backgrounded.
43
+ const work = Promise.resolve()
44
+ .then(() => executor(job))
45
+ .then((result) => { this.#finish(job, result); return { kind: 'result', result }; })
46
+ .catch((error) => { this.#fail(job, error); return { kind: 'error', error }; });
47
+
48
+ const raced = await Promise.race([
49
+ work,
50
+ new Promise((res) => setTimeout(() => res({ kind: 'timeout' }), graceMs)),
51
+ ]);
52
+
53
+ if (raced.kind === 'result') return { done: true, job, result: raced.result };
54
+ if (raced.kind === 'error') return { done: true, job, error: raced.error };
55
+
56
+ // Timed out the grace window -> run in background, let the AI poll.
57
+ job.backgrounded = true;
58
+ debug('job', `${tool}.${action || ''} backgrounded as ${job.id}`);
59
+ bus.emit('job:backgrounded', this.snapshot(job));
60
+ return { done: false, job };
61
+ }
62
+
63
+ /** Append a human-readable progress line to a running job (streamed to TUI). */
64
+ progress(jobId, message, extra = {}) {
65
+ const job = this.#jobs.get(jobId);
66
+ if (!job || job.status !== 'running') return;
67
+ const entry = { at: Date.now(), message, ...extra };
68
+ job.progress.push(entry);
69
+ if (job.progress.length > 100) job.progress.shift();
70
+ bus.emit('job:progress', { id: job.id, tool: job.tool, action: job.action, message, ...extra });
71
+ }
72
+
73
+ get(id) { return this.#jobs.get(id) || null; }
74
+
75
+ /** Most recent job (running preferred), for "status" with no explicit id. */
76
+ latest() {
77
+ for (let i = this.#order.length - 1; i >= 0; i--) {
78
+ const job = this.#jobs.get(this.#order[i]);
79
+ if (job && job.status === 'running') return job;
80
+ }
81
+ const lastId = this.#order[this.#order.length - 1];
82
+ return lastId ? this.#jobs.get(lastId) : null;
83
+ }
84
+
85
+ list() { return this.#order.map((id) => this.#jobs.get(id)).filter(Boolean); }
86
+
87
+ running() { return this.list().filter((j) => j.status === 'running'); }
88
+
89
+ /**
90
+ * Cooperative cancel: flag the job so cancel-aware executors can bail out.
91
+ * Non-cooperative work keeps running but its result is discarded.
92
+ */
93
+ cancel(id) {
94
+ const job = this.#jobs.get(id);
95
+ if (!job || job.status !== 'running') return false;
96
+ job.cancelRequested = true;
97
+ return true;
98
+ }
99
+
100
+ /** Wait up to `timeoutMs` for a job to leave the running state. */
101
+ async wait(id, timeoutMs = 20000) {
102
+ const job = this.#jobs.get(id);
103
+ if (!job) return null;
104
+ if (job.status !== 'running') return job;
105
+ return new Promise((resolve) => {
106
+ const done = () => { clearInterval(poll); clearTimeout(timer); bus.off('job:done', onDone); resolve(job); };
107
+ const onDone = (snap) => { if (snap.id === id) done(); };
108
+ const poll = setInterval(() => { if (job.status !== 'running') done(); }, 200);
109
+ const timer = setTimeout(() => { clearInterval(poll); bus.off('job:done', onDone); resolve(job); }, timeoutMs);
110
+ bus.on('job:done', onDone);
111
+ });
112
+ }
113
+
114
+ /** Plain, serializable view of a job for tool results / events. */
115
+ snapshot(job) {
116
+ if (!job) return null;
117
+ const now = job.finishedAt || Date.now();
118
+ return {
119
+ id: job.id,
120
+ tool: job.tool,
121
+ action: job.action,
122
+ label: job.label,
123
+ status: job.status,
124
+ backgrounded: !!job.backgrounded,
125
+ elapsed_s: Math.round((now - job.startedAt) / 1000),
126
+ progress: job.progress.slice(-5).map((p) => p.message),
127
+ error: job.error || undefined,
128
+ };
129
+ }
130
+
131
+ // ─── internals ───────────────────────────────────────────
132
+ #create({ tool, action, label }) {
133
+ const job = {
134
+ id: `job_${randomUUID().slice(0, 8)}`,
135
+ tool, action,
136
+ label: label || `${tool}${action ? `.${action}` : ''}`,
137
+ status: 'running',
138
+ startedAt: Date.now(),
139
+ finishedAt: null,
140
+ progress: [],
141
+ result: undefined,
142
+ error: null,
143
+ backgrounded: false,
144
+ cancelRequested: false,
145
+ };
146
+ this.#jobs.set(job.id, job);
147
+ this.#order.push(job.id);
148
+ this.#prune();
149
+ bus.emit('job:started', this.snapshot(job));
150
+ return job;
151
+ }
152
+
153
+ #finish(job, result) {
154
+ if (job.status !== 'running') return;
155
+ job.status = job.cancelRequested ? 'cancelled' : 'done';
156
+ job.finishedAt = Date.now();
157
+ job.result = this.#normalizeResult(result);
158
+ bus.emit('job:done', { ...this.snapshot(job), result: job.result });
159
+ }
160
+
161
+ #fail(job, error) {
162
+ if (job.status !== 'running') return;
163
+ job.status = 'error';
164
+ job.finishedAt = Date.now();
165
+ job.error = error?.message || String(error);
166
+ bus.emit('job:done', { ...this.snapshot(job), error: job.error });
167
+ }
168
+
169
+ /**
170
+ * Tool results come back in MCP shape ({ isError, content:[{text}] }). Unwrap
171
+ * to the raw payload so polling the `jobs` tool returns clean data.
172
+ */
173
+ #normalizeResult(result) {
174
+ if (result && Array.isArray(result.content) && result.content[0]?.type === 'text') {
175
+ const text = result.content[0].text;
176
+ try { return JSON.parse(text); } catch { return text; }
177
+ }
178
+ return result;
179
+ }
180
+
181
+ #prune() {
182
+ while (this.#order.length > MAX_JOBS) {
183
+ const oldest = this.#order.shift();
184
+ const job = this.#jobs.get(oldest);
185
+ // Never evict a job that is still running.
186
+ if (job && job.status === 'running') { this.#order.push(oldest); break; }
187
+ this.#jobs.delete(oldest);
188
+ }
189
+ }
190
+ }
191
+
192
+ export const jobManager = new JobManager();
@@ -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,24 +51,83 @@ 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
+ // Trimmed descriptions to stay under XiaoZhi broker 8KB limit
55
+ const desc = {
56
+ agent: 'Agent: spawn, list, status',
57
+ ai: 'Code analysis, summarize, diff',
58
+ config: 'Config: get, set, list, reset',
59
+ diff: 'File comparison: compare, patch',
60
+ env: 'Env variables: get, set, list',
61
+ file: 'File ops: read, write, edit, delete, copy',
62
+ git: 'Git: init, clone, status, diff, commit, push',
63
+ history: 'Tool history: list, clear, stats',
64
+ jobs: 'Poll background jobs: list, status, wait, result, cancel',
65
+ network: 'Network: fetch, download, ping',
66
+ process: 'Process: list, info, kill, top',
67
+ project: 'Project: init, build, test, lint',
68
+ search: 'Search: grep, find, web fetch',
69
+ shell: 'Shell: exec, background, ps, kill',
70
+ snippet: 'Snippets: save, load, list, delete',
71
+ template: 'Templates: list, create, info',
72
+ watch: 'File watch: start, stop, status',
73
+ };
74
+ return this.all().map(t => {
75
+ const schema = t.inputSchema || { type: 'object', properties: {} };
76
+ const compact = { type: 'object', properties: {} };
77
+ // Send all properties with type + enum only (no descriptions)
78
+ if (schema.properties) {
79
+ for (const [k, v] of Object.entries(schema.properties)) {
80
+ const prop = { type: v.type };
81
+ if (v.enum) prop.enum = v.enum;
82
+ compact.properties[k] = prop;
83
+ }
84
+ }
85
+ // Include per-action required fields in the description so the AI
86
+ // knows exactly which params each action needs (MCP schema only
87
+ // supports a flat required array, not conditional per-action).
88
+ let description = desc[t.name] || t.description;
89
+ if (t.actions?.length) {
90
+ const actionReqs = t.actions
91
+ .filter(a => a.required?.length)
92
+ .map(a => `${a.name}→${a.required.join(',')}`)
93
+ .join('; ');
94
+ if (actionReqs) description += ` [required: ${actionReqs}]`;
95
+ }
96
+ return { name: t.name, description, inputSchema: compact };
97
+ });
59
98
  }
60
99
 
61
100
  async call(name, args = {}) {
62
101
  const tool = this.#tools.get(name);
63
102
  if (!tool) return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
64
103
  try {
65
- const result = await tool.handler(args);
104
+ const result = await tool.handler(args, this._toolsRef());
66
105
  return { isError: false, content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
67
106
  } catch (e) {
68
107
  return { isError: true, content: [{ type: 'text', text: e.message }] };
69
108
  }
70
109
  }
71
110
 
111
+ /** Call a tool and return parsed result (for internal tool-to-tool calls) */
112
+ async callParsed(name, args = {}) {
113
+ const tool = this.#tools.get(name);
114
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
115
+ return await tool.handler(args, this._toolsRef());
116
+ }
117
+
118
+ /**
119
+ * The reference handed to every tool handler. Beyond `call`, it exposes tool
120
+ * discovery (`has` / `names`) so orchestrator tools can adapt dynamically to
121
+ * whatever tools are actually loaded instead of hardcoding a fixed pipeline.
122
+ */
123
+ _toolsRef() {
124
+ return {
125
+ call: (toolName, toolArgs) => this.callParsed(toolName, toolArgs),
126
+ has: (toolName) => this.#tools.has(toolName),
127
+ names: () => [...this.#tools.keys()],
128
+ };
129
+ }
130
+
72
131
  async shutdown() {
73
132
  for (const tool of this.#tools.values()) {
74
133
  if (tool.shutdown) await tool.shutdown();