pelulu-cli 1.0.0 → 1.0.3
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/README.md +69 -82
- package/config.example.json +2 -1
- package/package.json +6 -3
- package/preview.jpg +0 -0
- package/src/core/auto-format.js +61 -12
- package/src/core/config.js +1 -1
- package/src/core/confirm.js +2 -2
- package/src/core/diff.js +4 -4
- package/src/core/doctor.js +2 -2
- package/src/core/error-handler.js +1 -1
- package/src/core/file-tracker.js +4 -4
- package/src/core/formatter.js +19 -19
- package/src/core/logger.js +19 -3
- package/src/core/spinner.js +2 -2
- package/src/core/stats.js +2 -2
- package/src/core/thinking.js +8 -8
- package/src/core/tool-help.js +2 -2
- package/src/core/tool-registry.js +6 -10
- package/src/core/update-checker.js +125 -0
- package/src/core/wizard.js +5 -5
- package/src/core/workspace.js +4 -4
- package/src/index.js +89 -37
- package/src/mcp/activation.js +2 -5
- package/src/mcp/message-sender.js +1 -1
- package/src/mcp/mqtt-client.js +4 -4
- package/src/repl-commands.js +2 -1
- package/src/repl.js +26 -6
- package/src/tools/config.js +1 -1
- package/src/tools/diff.js +6 -0
- package/src/tools/file.js +3 -3
- package/src/tools/git.js +1 -1
- package/src/tools/network.js +1 -1
- package/src/tools/project.js +2 -2
- package/src/tools/search.js +3 -3
- package/src/tools/shell.js +2 -2
- package/src/tools/template.js +1 -1
- package/src/tools/watch.js +1 -1
- package/src/tui/completable-input.js +127 -0
- package/src/tui/ink-app.js +324 -0
- package/src/tui/ink-components.js +157 -0
- package/src/tui/ink-entry.js +86 -0
- package/src/tui/renderer.js +137 -19
- package/src/tui/status-bar.js +7 -7
package/src/core/tool-help.js
CHANGED
|
@@ -99,7 +99,7 @@ export function showToolHelp(toolName) {
|
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
console.log(`\n${COLORS.bold}
|
|
102
|
+
console.log(`\n${COLORS.bold}[DOC] ${toolName} — Examples:${COLORS.reset}\n`);
|
|
103
103
|
for (const ex of examples) {
|
|
104
104
|
console.log(` ${COLORS.cyan}${ex}${COLORS.reset}`);
|
|
105
105
|
}
|
|
@@ -108,7 +108,7 @@ export function showToolHelp(toolName) {
|
|
|
108
108
|
|
|
109
109
|
export function showAllToolHelp() {
|
|
110
110
|
for (const [tool, examples] of Object.entries(TOOL_EXAMPLES)) {
|
|
111
|
-
console.log(`\n${COLORS.bold}
|
|
111
|
+
console.log(`\n${COLORS.bold}[DOC] ${tool}:${COLORS.reset}`);
|
|
112
112
|
for (const ex of examples.slice(0, 2)) {
|
|
113
113
|
console.log(` ${COLORS.cyan}${ex}${COLORS.reset}`);
|
|
114
114
|
}
|
|
@@ -18,23 +18,19 @@ export class ToolRegistry {
|
|
|
18
18
|
#tools = new Map();
|
|
19
19
|
|
|
20
20
|
async loadBuiltins() {
|
|
21
|
+
const failed = [];
|
|
21
22
|
try {
|
|
22
23
|
const files = await readdir(TOOLS_DIR);
|
|
23
24
|
for (const file of files.filter(f => f.endsWith('.js'))) {
|
|
24
25
|
try {
|
|
25
26
|
const mod = await import(join(TOOLS_DIR, file));
|
|
26
27
|
const tool = mod.default || mod;
|
|
27
|
-
if (tool?.name && tool?.handler)
|
|
28
|
-
|
|
29
|
-
log('tool', ` ${tool.name} — ${tool.description || ''} (${tool.actions?.length || 0} actions)`);
|
|
30
|
-
}
|
|
31
|
-
} catch (e) {
|
|
32
|
-
log('warn', `Tool "${file}" failed: ${e.message}`);
|
|
33
|
-
}
|
|
28
|
+
if (tool?.name && tool?.handler) this.register(tool);
|
|
29
|
+
} catch (e) { failed.push(file); }
|
|
34
30
|
}
|
|
35
|
-
} catch (e) {
|
|
36
|
-
|
|
37
|
-
}
|
|
31
|
+
} catch (e) { /* silent */ }
|
|
32
|
+
if (failed.length) log('warn', `Failed tools: ${failed.join(', ')}`);
|
|
33
|
+
return { loaded: this.#tools.size, failed: failed.length };
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
register(tool) {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update Checker — compare local version with GitHub releases
|
|
3
|
+
* Uses: https://api.github.com/repos/venenapro/Pelulu-CLI/releases
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from 'fs/promises';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/venenapro/Pelulu-CLI/releases';
|
|
9
|
+
const TIMEOUT_MS = 8000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse semver string "1.2.3" → { major, minor, patch }
|
|
13
|
+
*/
|
|
14
|
+
function parseSemver(version) {
|
|
15
|
+
const clean = version.replace(/^v/i, '');
|
|
16
|
+
const match = clean.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
return { major: +match[1], minor: +match[2], patch: +match[3] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compare two semver objects.
|
|
23
|
+
* Returns: 1 if a > b, -1 if a < b, 0 if equal
|
|
24
|
+
*/
|
|
25
|
+
function compareSemver(a, b) {
|
|
26
|
+
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
|
|
27
|
+
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
|
|
28
|
+
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read local version from package.json
|
|
34
|
+
*/
|
|
35
|
+
async function getLocalVersion(root) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = await readFile(join(root, 'package.json'), 'utf-8');
|
|
38
|
+
const pkg = JSON.parse(raw);
|
|
39
|
+
return pkg.version || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch latest release from GitHub API.
|
|
47
|
+
* Returns { tag, name, body, url, publishedAt } or null on failure.
|
|
48
|
+
*/
|
|
49
|
+
async function fetchLatestRelease() {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(GITHUB_RELEASES_URL, {
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
headers: {
|
|
57
|
+
'Accept': 'application/vnd.github+json',
|
|
58
|
+
'User-Agent': 'Pelulu-CLI',
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!res.ok) return null;
|
|
63
|
+
|
|
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];
|
|
69
|
+
|
|
70
|
+
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 || '',
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check for updates.
|
|
86
|
+
* Returns:
|
|
87
|
+
* { available: true, local, remote, release } — update available
|
|
88
|
+
* { available: false, local, remote } — up to date
|
|
89
|
+
* { available: false, error: true, message } — check failed
|
|
90
|
+
*/
|
|
91
|
+
export async function checkForUpdates(root) {
|
|
92
|
+
const localVersion = await getLocalVersion(root);
|
|
93
|
+
if (!localVersion) {
|
|
94
|
+
return { available: false, error: true, message: 'Could not read local version from package.json' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const release = await fetchLatestRelease();
|
|
98
|
+
if (!release) {
|
|
99
|
+
return { available: false, error: true, local: localVersion, message: 'Could not fetch GitHub releases' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const localParsed = parseSemver(localVersion);
|
|
103
|
+
const remoteParsed = parseSemver(release.tag);
|
|
104
|
+
|
|
105
|
+
if (!localParsed || !remoteParsed) {
|
|
106
|
+
return { available: false, error: true, local: localVersion, remote: release.tag, message: 'Invalid version format' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const cmp = compareSemver(remoteParsed, localParsed);
|
|
110
|
+
|
|
111
|
+
if (cmp > 0) {
|
|
112
|
+
return {
|
|
113
|
+
available: true,
|
|
114
|
+
local: localVersion,
|
|
115
|
+
remote: release.tag.replace(/^v/i, ''),
|
|
116
|
+
release,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
available: false,
|
|
122
|
+
local: localVersion,
|
|
123
|
+
remote: release.tag.replace(/^v/i, ''),
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/core/wizard.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function runWizard(root) {
|
|
|
12
12
|
if (config._wizard_done) return config;
|
|
13
13
|
|
|
14
14
|
console.log(`\n${COLORS.cyan}╔══════════════════════════════════════╗`);
|
|
15
|
-
console.log(`║
|
|
15
|
+
console.log(`║ Pelulu CLI Setup ║`);
|
|
16
16
|
console.log(`╚══════════════════════════════════════╝${COLORS.reset}\n`);
|
|
17
17
|
|
|
18
18
|
if (!process.stdin.isTTY) {
|
|
@@ -31,23 +31,23 @@ export async function runWizard(root) {
|
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
33
|
// Workspace
|
|
34
|
-
const workspace = await ask('
|
|
34
|
+
const workspace = await ask('[DIR] Workspace path?', config.agent?.workspace || '~/coding-agent');
|
|
35
35
|
config.agent = { ...config.agent, workspace };
|
|
36
36
|
|
|
37
37
|
// MCP endpoint (optional)
|
|
38
38
|
console.log(`\n${COLORS.dim}MCP Endpoint (optional, for official XiaoZhi MCP):${COLORS.reset}`);
|
|
39
39
|
console.log(`${COLORS.dim}Get from: xiaozhi.me → Configure → Extensions → MCP Endpoint${COLORS.reset}`);
|
|
40
|
-
const mcpUrl = await ask('
|
|
40
|
+
const mcpUrl = await ask('[MCP] MCP Endpoint URL?', '');
|
|
41
41
|
if (mcpUrl) config.mcp = { ...config.mcp, endpoint_url: mcpUrl };
|
|
42
42
|
|
|
43
43
|
// Shell timeout
|
|
44
|
-
const timeout = await ask('
|
|
44
|
+
const timeout = await ask('Shell timeout (ms)?', String(config.tools?.shell_timeout || 30000));
|
|
45
45
|
config.tools = { ...config.tools, shell_timeout: parseInt(timeout) || 30000 };
|
|
46
46
|
|
|
47
47
|
config._wizard_done = true;
|
|
48
48
|
await saveConfig(root, config);
|
|
49
49
|
|
|
50
|
-
console.log(`\n${COLORS.green}
|
|
50
|
+
console.log(`\n${COLORS.green}[OK] Configuration saved!${COLORS.reset}\n`);
|
|
51
51
|
return config;
|
|
52
52
|
} finally {
|
|
53
53
|
rl.close();
|
package/src/core/workspace.js
CHANGED
|
@@ -82,15 +82,15 @@ export async function detectWorkspace(dir) {
|
|
|
82
82
|
|
|
83
83
|
export function formatWorkspace(info) {
|
|
84
84
|
const lines = [
|
|
85
|
-
|
|
85
|
+
`[DIR] ${info.name} (${info.type})`,
|
|
86
86
|
` Language: ${info.language}`,
|
|
87
87
|
` Path: ${info.path}`,
|
|
88
88
|
];
|
|
89
89
|
if (info.version) lines.push(` Version: ${info.version}`);
|
|
90
90
|
if (info.scripts?.length) lines.push(` Scripts: ${info.scripts.join(', ')}`);
|
|
91
91
|
if (info.dirs?.length) lines.push(` Dirs: ${info.dirs.join(', ')}`);
|
|
92
|
-
if (info.hasGit) lines.push(' Git:
|
|
93
|
-
if (info.hasDocker) lines.push(' Docker:
|
|
94
|
-
if (info.hasCI) lines.push(' CI:
|
|
92
|
+
if (info.hasGit) lines.push(' Git: [OK]');
|
|
93
|
+
if (info.hasDocker) lines.push(' Docker: [OK]');
|
|
94
|
+
if (info.hasCI) lines.push(' CI: [OK]');
|
|
95
95
|
return lines.join('\n');
|
|
96
96
|
}
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Pelulu CLI — Entry Point
|
|
4
|
+
* Ink-based TUI with React components
|
|
5
5
|
*/
|
|
6
6
|
import { dirname, join } from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
|
+
import chalk from 'chalk';
|
|
8
9
|
import { loadConfig, saveConfig } from './core/config.js';
|
|
9
10
|
import { log, setDebug } from './core/logger.js';
|
|
10
11
|
import { bus } from './core/event-bus.js';
|
|
@@ -16,44 +17,70 @@ import { buildSystemPrompt } from './core/system-prompt.js';
|
|
|
16
17
|
import { buildContext } from './core/context.js';
|
|
17
18
|
import { isDestructive, askConfirmation } from './core/confirm.js';
|
|
18
19
|
import { runWizard } from './core/wizard.js';
|
|
20
|
+
import { handleError } from './core/error-handler.js';
|
|
21
|
+
import { withRetry } from './core/retry.js';
|
|
22
|
+
import { withSpinner } from './core/spinner.js';
|
|
23
|
+
import { Thinking } from './core/thinking.js';
|
|
24
|
+
import { autoFormat } from './core/auto-format.js';
|
|
25
|
+
import { FileTracker } from './core/file-tracker.js';
|
|
19
26
|
import { MqttClient } from './mcp/mqtt-client.js';
|
|
27
|
+
import { MessageSender } from './mcp/message-sender.js';
|
|
20
28
|
import { WssEndpoint } from './mcp/wss-endpoint.js';
|
|
21
29
|
import { PluginManager } from './plugins/manager.js';
|
|
22
|
-
import {
|
|
30
|
+
import { checkForUpdates } from './core/update-checker.js';
|
|
31
|
+
import { renderUpdateNotification, renderAsciiBanner } from './tui/renderer.js';
|
|
32
|
+
import { startInkTUI } from './tui/ink-entry.js';
|
|
23
33
|
|
|
24
34
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
35
|
const ROOT = join(__dirname, '..');
|
|
26
36
|
|
|
27
37
|
const args = process.argv.slice(2);
|
|
28
38
|
if (args.includes('--debug')) setDebug(true);
|
|
29
|
-
if (args.includes('--wizard')) { /* force wizard */ }
|
|
30
39
|
const LIST_TOOLS = args.includes('--list-tools');
|
|
31
40
|
|
|
32
41
|
async function main() {
|
|
33
|
-
//
|
|
42
|
+
// Buffer ALL startup logs for Ink to display inside TUI
|
|
43
|
+
const startupLogs = [];
|
|
44
|
+
const origLog = console.log;
|
|
45
|
+
const bufferLog = (...args) => startupLogs.push(args.join(' '));
|
|
46
|
+
|
|
47
|
+
// 1. Load config
|
|
34
48
|
const config = await loadConfig(ROOT);
|
|
35
49
|
|
|
36
|
-
// 2.
|
|
50
|
+
// 2. Check for updates — block if outdated
|
|
51
|
+
const update = await checkForUpdates(ROOT);
|
|
52
|
+
if (update.available) {
|
|
53
|
+
renderUpdateNotification(update);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 3. Workspace & wizard
|
|
37
58
|
const cwd = process.cwd();
|
|
38
59
|
config.agent = { ...config.agent, workspace: cwd };
|
|
39
|
-
log('info', `Workspace: ${cwd}`);
|
|
40
|
-
|
|
41
60
|
if (!config._wizard_done || args.includes('--wizard')) {
|
|
42
61
|
await runWizard(ROOT);
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
//
|
|
64
|
+
// Show ASCII banner before Ink takes over
|
|
65
|
+
renderAsciiBanner(config.agent?.version);
|
|
66
|
+
|
|
67
|
+
// Redirect console.log to buffer — everything after this goes into Ink
|
|
68
|
+
console.log = bufferLog;
|
|
69
|
+
|
|
70
|
+
// 4. Load tools
|
|
46
71
|
const registry = new ToolRegistry();
|
|
47
|
-
|
|
48
|
-
await registry.loadBuiltins();
|
|
72
|
+
const pluginMgr = new PluginManager(registry);
|
|
49
73
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
const { loaded } = await withSpinner('Loading tools...', async () => {
|
|
75
|
+
return await registry.loadBuiltins();
|
|
76
|
+
});
|
|
77
|
+
await pluginMgr.load();
|
|
78
|
+
const actions = registry.all().reduce((s, t) => s + (t.actions?.length || 0), 0);
|
|
53
79
|
|
|
54
80
|
if (LIST_TOOLS) {
|
|
81
|
+
console.log = origLog;
|
|
55
82
|
const tools = registry.list();
|
|
56
|
-
console.log('\
|
|
83
|
+
console.log('\nTools:\n');
|
|
57
84
|
for (const t of tools) console.log(` ${t.name} — ${t.description} (${t.actions.join(', ')})`);
|
|
58
85
|
console.log(`\n ${tools.length} tools, ${tools.reduce((s, t) => s + t.actions.length, 0)} actions\n`);
|
|
59
86
|
process.exit(0);
|
|
@@ -63,33 +90,42 @@ async function main() {
|
|
|
63
90
|
const sandbox = new Sandbox();
|
|
64
91
|
const session = new SessionState();
|
|
65
92
|
const stats = new Stats();
|
|
93
|
+
const thinking = new Thinking();
|
|
94
|
+
const fileTracker = new FileTracker();
|
|
66
95
|
|
|
67
96
|
// 6. Build context & system prompt
|
|
68
97
|
const context = await buildContext();
|
|
69
98
|
const systemPrompt = buildSystemPrompt(registry, config);
|
|
70
|
-
log('info', `Context: ${context.split('\n').length} lines`);
|
|
71
99
|
|
|
72
100
|
// 7. Connect to XiaoZhi
|
|
73
101
|
const mqtt = new MqttClient(config);
|
|
74
102
|
|
|
75
103
|
bus.on('activation:required', ({ code }) => {
|
|
76
|
-
console
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
// Display DIRECTLY to console (not buffered) — user must see this!
|
|
105
|
+
origLog('');
|
|
106
|
+
origLog(chalk.red(' ╔══════════════════════════════════════════╗'));
|
|
107
|
+
origLog(chalk.red(' ║') + chalk.bold.white(' ACTIVATION REQUIRED ') + chalk.red('║'));
|
|
108
|
+
origLog(chalk.red(' ╠══════════════════════════════════════════╣'));
|
|
109
|
+
origLog(chalk.red(' ║') + chalk.yellow(` Code: ${code}`) + ' '.repeat(33 - code.length) + chalk.red('║'));
|
|
110
|
+
origLog(chalk.red(' ║') + chalk.cyan(' https://xiaozhi.me') + ' '.repeat(23) + chalk.red('║'));
|
|
111
|
+
origLog(chalk.red(' ╚══════════════════════════════════════════╝'));
|
|
112
|
+
origLog('');
|
|
113
|
+
origLog(chalk.gray(' Waiting for activation... (checking every 5s)'));
|
|
114
|
+
origLog('');
|
|
82
115
|
});
|
|
83
116
|
|
|
84
117
|
try {
|
|
85
|
-
|
|
86
|
-
await mqtt.connect();
|
|
118
|
+
await withRetry(() => mqtt.connect(), { maxRetries: 2, delay: 2000 });
|
|
87
119
|
} catch (e) {
|
|
88
|
-
log
|
|
120
|
+
console.log = origLog;
|
|
121
|
+
console.error(chalk.red(`\n Connection failed: ${e.message}\n`));
|
|
89
122
|
process.exit(1);
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
// 8.
|
|
125
|
+
// 8. Message sender
|
|
126
|
+
const sender = new MessageSender(mqtt);
|
|
127
|
+
|
|
128
|
+
// 9. Register MCP tool handler
|
|
93
129
|
mqtt.registerToolHandler(
|
|
94
130
|
async (name, args) => {
|
|
95
131
|
const destructive = isDestructive(name, args);
|
|
@@ -98,24 +134,31 @@ async function main() {
|
|
|
98
134
|
if (!ok) return { isError: true, content: [{ type: 'text', text: 'Cancelled by user' }] };
|
|
99
135
|
}
|
|
100
136
|
sandbox.validate(name, args);
|
|
137
|
+
|
|
138
|
+
thinking.set('tool_call');
|
|
101
139
|
const start = Date.now();
|
|
102
|
-
log('tool', `🔧 ${name} → ${args.action || '-'}`);
|
|
103
140
|
try {
|
|
104
141
|
const result = await registry.call(name, args);
|
|
105
142
|
stats.record(name, args.action, !result.isError, Date.now() - start);
|
|
106
143
|
session.addToolCall(name, args, result);
|
|
107
|
-
|
|
144
|
+
|
|
145
|
+
if (name === 'file' && (args.action === 'write' || args.action === 'edit') && args.path) {
|
|
146
|
+
autoFormat(args.path).catch(() => {});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
thinking.set('idle');
|
|
108
150
|
bus.emit('tool:called', { name, result, args });
|
|
109
151
|
return result;
|
|
110
152
|
} catch (e) {
|
|
111
153
|
stats.record(name, args.action, false, Date.now() - start, e.message);
|
|
112
|
-
|
|
154
|
+
thinking.set('idle');
|
|
155
|
+
throw new Error(handleError(e));
|
|
113
156
|
}
|
|
114
157
|
},
|
|
115
158
|
() => registry.toMcpTools()
|
|
116
159
|
);
|
|
117
160
|
|
|
118
|
-
//
|
|
161
|
+
// 10. Optional WSS endpoint
|
|
119
162
|
let wss = null;
|
|
120
163
|
if (config.mcp?.endpoint_url) {
|
|
121
164
|
wss = new WssEndpoint(config.mcp.endpoint_url, () => registry.toMcpTools(), async (name, args) => {
|
|
@@ -125,19 +168,22 @@ async function main() {
|
|
|
125
168
|
wss.start();
|
|
126
169
|
}
|
|
127
170
|
|
|
128
|
-
//
|
|
171
|
+
// 11. Track conversation
|
|
129
172
|
bus.on('user:text', (text) => session.addUserMessage(text));
|
|
130
173
|
bus.on('llm:text', (text) => session.addAiMessage(text));
|
|
131
174
|
|
|
132
|
-
//
|
|
175
|
+
// 12. Save config
|
|
133
176
|
await saveConfig(ROOT, config);
|
|
134
|
-
|
|
135
|
-
|
|
177
|
+
|
|
178
|
+
// 13. Start Ink TUI
|
|
179
|
+
const { unmount, waitUntilExit } = startInkTUI({
|
|
180
|
+
registry, mqtt, stats, session, bus, config,
|
|
181
|
+
extras: { fileTracker, thinking, sender, autoFormat, startupLogs },
|
|
182
|
+
});
|
|
136
183
|
|
|
137
184
|
// Graceful shutdown
|
|
138
185
|
const shutdown = async () => {
|
|
139
|
-
|
|
140
|
-
console.log(stats.formatReport());
|
|
186
|
+
unmount();
|
|
141
187
|
if (wss) wss.stop();
|
|
142
188
|
await registry.shutdown();
|
|
143
189
|
mqtt.disconnect();
|
|
@@ -145,6 +191,12 @@ async function main() {
|
|
|
145
191
|
};
|
|
146
192
|
process.on('SIGINT', shutdown);
|
|
147
193
|
process.on('SIGTERM', shutdown);
|
|
194
|
+
|
|
195
|
+
await waitUntilExit();
|
|
196
|
+
await shutdown();
|
|
148
197
|
}
|
|
149
198
|
|
|
150
|
-
main().catch(e => {
|
|
199
|
+
main().catch(e => {
|
|
200
|
+
console.error(chalk.red(`\n Fatal: ${handleError(e)}\n`));
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|
package/src/mcp/activation.js
CHANGED
|
@@ -33,9 +33,6 @@ export async function handleActivation(data, otaUrl, deviceId, clientId) {
|
|
|
33
33
|
const a = data.activation;
|
|
34
34
|
if (!a?.code) return data.mqtt;
|
|
35
35
|
|
|
36
|
-
log('warn', `🔐 Activation required`);
|
|
37
|
-
log('info', `📋 Code: ${a.code}`);
|
|
38
|
-
log('info', `🌐 https://xiaozhi.me`);
|
|
39
36
|
bus.emit('activation:required', { code: a.code });
|
|
40
37
|
|
|
41
38
|
const timeout = a.timeout_ms || 120000;
|
|
@@ -43,10 +40,10 @@ export async function handleActivation(data, otaUrl, deviceId, clientId) {
|
|
|
43
40
|
|
|
44
41
|
while (Date.now() - start < timeout) {
|
|
45
42
|
await new Promise(r => setTimeout(r, 5000));
|
|
46
|
-
log('info', '
|
|
43
|
+
log('info', 'Checking activation...');
|
|
47
44
|
const poll = await fetchOtaConfig(otaUrl, deviceId, clientId);
|
|
48
45
|
if (!poll.activation?.code && poll.mqtt) {
|
|
49
|
-
log('ok', '
|
|
46
|
+
log('ok', 'Activated!');
|
|
50
47
|
return poll.mqtt;
|
|
51
48
|
}
|
|
52
49
|
}
|
|
@@ -70,7 +70,7 @@ export class MessageSender {
|
|
|
70
70
|
async _waitForReady() {
|
|
71
71
|
if (this.mqtt.mcp.toolsReceived) return;
|
|
72
72
|
|
|
73
|
-
log('info', '
|
|
73
|
+
log('info', '[...] Waiting for MCP handshake...');
|
|
74
74
|
return new Promise((resolve, reject) => {
|
|
75
75
|
const timeout = setTimeout(() => reject(new Error('MCP handshake timeout')), 30000);
|
|
76
76
|
const check = setInterval(() => {
|
package/src/mcp/mqtt-client.js
CHANGED
|
@@ -111,9 +111,9 @@ export class MqttClient {
|
|
|
111
111
|
for (const r of responses) {
|
|
112
112
|
if (r.type === 'mcp') { this._send(r); }
|
|
113
113
|
else if (r.type === 'tool_call') {
|
|
114
|
-
log('tool',
|
|
114
|
+
log('tool', r.name);
|
|
115
115
|
this.mcp.executeTool(r.name, r.args)
|
|
116
|
-
.then(result => { log('tool', result.isError ? '
|
|
116
|
+
.then(result => { log('tool', result.isError ? 'failed' : 'done'); this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } }); })
|
|
117
117
|
.catch(err => { this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: err.message }], isError: true } } }); });
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -124,7 +124,7 @@ export class MqttClient {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
_onOther(msg) {
|
|
127
|
-
if (msg.type === 'stt') { log('user',
|
|
127
|
+
if (msg.type === 'stt') { log('user', `"${msg.text}"`); bus.emit('stt', msg.text); }
|
|
128
128
|
if (msg.type === 'llm' && msg.text) { bus.emit('llm:text', msg.text); }
|
|
129
129
|
if (msg.type === 'tts' && msg.state === 'sentence_start' && msg.text) { bus.emit('tts:sentence', msg.text); }
|
|
130
130
|
if (msg.type === 'goodbye') { this.sessionId = null; this.mcp.reset(); this._helloQueue = []; }
|
|
@@ -138,7 +138,7 @@ export class MqttClient {
|
|
|
138
138
|
|
|
139
139
|
sendText(text) {
|
|
140
140
|
this._send({ type: 'listen', state: 'detect', text });
|
|
141
|
-
log('user',
|
|
141
|
+
log('user', `"${text}"`);
|
|
142
142
|
bus.emit('user:text', text);
|
|
143
143
|
}
|
|
144
144
|
|
package/src/repl-commands.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function handleCommand(cmd, arg, ctx) {
|
|
|
76
76
|
case '/history': {
|
|
77
77
|
const recent = history.slice(-10);
|
|
78
78
|
if (!recent.length) { log('info', 'No history'); break; }
|
|
79
|
-
console.log(`\n${COLORS.bold}
|
|
79
|
+
console.log(`\n${COLORS.bold}[LOG] Recent:${COLORS.reset}\n`);
|
|
80
80
|
for (const h of recent) console.log(` ${COLORS.dim}${new Date(h.ts).toLocaleTimeString()}${COLORS.reset} ${h.text.slice(0, 80)}`);
|
|
81
81
|
console.log();
|
|
82
82
|
break;
|
|
@@ -84,6 +84,7 @@ export async function handleCommand(cmd, arg, ctx) {
|
|
|
84
84
|
|
|
85
85
|
case '/doctor': await runDoctor(); break;
|
|
86
86
|
case '/model': console.log(`\n${formatModelInfo()}\n`); break;
|
|
87
|
+
case '/keys': return 'keys';
|
|
87
88
|
case '/clear': console.clear(); break;
|
|
88
89
|
default: log('info', `Unknown: ${cmd}. /help`);
|
|
89
90
|
}
|
package/src/repl.js
CHANGED
|
@@ -9,6 +9,8 @@ import { formatToolResult } from './core/formatter.js';
|
|
|
9
9
|
import { parseIntent } from './core/intent.js';
|
|
10
10
|
import { FileTracker } from './core/file-tracker.js';
|
|
11
11
|
import { handleCommand } from './repl-commands.js';
|
|
12
|
+
import { getCompletions } from './core/completion.js';
|
|
13
|
+
import { formatKeybindings } from './core/keybindings.js';
|
|
12
14
|
import {
|
|
13
15
|
renderBanner, renderStatus, renderTools, renderHelp,
|
|
14
16
|
renderToolCall, renderToolResult, renderAiResponse,
|
|
@@ -16,19 +18,21 @@ import {
|
|
|
16
18
|
} from './tui/renderer.js';
|
|
17
19
|
|
|
18
20
|
export class REPL {
|
|
19
|
-
constructor(registry, mqtt, stats, session) {
|
|
21
|
+
constructor(registry, mqtt, stats, session, extras = {}) {
|
|
20
22
|
this.registry = registry;
|
|
21
23
|
this.mqtt = mqtt;
|
|
22
24
|
this.stats = stats;
|
|
23
25
|
this.session = session;
|
|
24
26
|
this.fileTracker = new FileTracker();
|
|
27
|
+
this.thinking = extras.thinking || null;
|
|
28
|
+
this.sender = extras.sender || null;
|
|
29
|
+
this.autoFormat = extras.autoFormat || null;
|
|
25
30
|
this.rl = null;
|
|
26
31
|
this.history = [];
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
start() {
|
|
30
35
|
const config = getConfig();
|
|
31
|
-
renderBanner(config, this.registry.all(), this.mqtt.connected);
|
|
32
36
|
this._events();
|
|
33
37
|
|
|
34
38
|
if (!process.stdin.isTTY) {
|
|
@@ -41,6 +45,10 @@ export class REPL {
|
|
|
41
45
|
this.rl = createInterface({
|
|
42
46
|
input: process.stdin, output: process.stdout,
|
|
43
47
|
prompt: createPrompt(dirName), historySize: 200,
|
|
48
|
+
completer: (line) => {
|
|
49
|
+
const hits = getCompletions(line);
|
|
50
|
+
return [hits.length ? hits : [], line];
|
|
51
|
+
},
|
|
44
52
|
});
|
|
45
53
|
this.rl.prompt();
|
|
46
54
|
this.rl.on('line', (input) => this._line(input.trim()));
|
|
@@ -49,12 +57,15 @@ export class REPL {
|
|
|
49
57
|
|
|
50
58
|
_events() {
|
|
51
59
|
bus.on('llm:text', (text) => {
|
|
60
|
+
if (this.thinking) this.thinking.set('idle');
|
|
61
|
+
// Clear any thinking indicator line
|
|
62
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
52
63
|
renderAiResponse(text);
|
|
53
64
|
if (this.rl) this.rl.prompt();
|
|
54
65
|
});
|
|
55
66
|
|
|
56
67
|
bus.on('tts:sentence', (text) => {
|
|
57
|
-
|
|
68
|
+
// TTS is audio output, no need to display text
|
|
58
69
|
});
|
|
59
70
|
|
|
60
71
|
bus.on('tool:called', ({ name, result, args }) => {
|
|
@@ -63,7 +74,7 @@ export class REPL {
|
|
|
63
74
|
});
|
|
64
75
|
|
|
65
76
|
bus.on('ready', () => {
|
|
66
|
-
|
|
77
|
+
if (this.rl) this.rl.prompt();
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -75,7 +86,11 @@ export class REPL {
|
|
|
75
86
|
const intent = parseIntent(input);
|
|
76
87
|
if (intent.matched) {
|
|
77
88
|
renderUserInput(input);
|
|
89
|
+
if (this.thinking) this.thinking.set('tool_call');
|
|
78
90
|
const result = await this.registry.call(intent.tool, intent.params);
|
|
91
|
+
if (this.thinking) this.thinking.set('idle');
|
|
92
|
+
// Clear thinking indicator
|
|
93
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
79
94
|
const formatted = formatToolResult(intent.tool, intent.action, result);
|
|
80
95
|
console.log(`\n${formatted}\n`);
|
|
81
96
|
this.history.push({ role: 'user', text: input, ts: Date.now() });
|
|
@@ -86,6 +101,7 @@ export class REPL {
|
|
|
86
101
|
// Send to XiaoZhi
|
|
87
102
|
renderUserInput(input);
|
|
88
103
|
this.history.push({ role: 'user', text: input, ts: Date.now() });
|
|
104
|
+
if (this.thinking) this.thinking.set('thinking');
|
|
89
105
|
this.mqtt.sendText(input);
|
|
90
106
|
this.rl?.prompt();
|
|
91
107
|
}
|
|
@@ -93,15 +109,19 @@ export class REPL {
|
|
|
93
109
|
async _cmd(input) {
|
|
94
110
|
const [cmd, ...rest] = input.split(' ');
|
|
95
111
|
const arg = rest.join(' ');
|
|
96
|
-
const ctx = {
|
|
112
|
+
const ctx = {
|
|
113
|
+
registry: this.registry, mqtt: this.mqtt, stats: this.stats,
|
|
114
|
+
session: this.session, fileTracker: this.fileTracker, history: this.history,
|
|
115
|
+
};
|
|
97
116
|
const special = await handleCommand(cmd, arg, ctx);
|
|
98
117
|
|
|
99
118
|
if (special === 'help') renderHelp();
|
|
100
119
|
if (special === 'tools') renderTools(this.registry.list());
|
|
120
|
+
if (special === 'keys') console.log(formatKeybindings());
|
|
101
121
|
if (special === 'status') {
|
|
102
122
|
const s = this.session.getStats();
|
|
103
123
|
renderStatus({
|
|
104
|
-
'MQTT': this.mqtt.connected ? '
|
|
124
|
+
'MQTT': this.mqtt.connected ? '[OK] Connected' : '[ERR] Disconnected',
|
|
105
125
|
'Session': this.mqtt.sessionId || 'none',
|
|
106
126
|
'Tools': `${this.registry.all().length} (${this.registry.all().reduce((s, t) => s + (t.actions?.length || 0), 0)} actions)`,
|
|
107
127
|
'Turns': s.turns,
|
package/src/tools/config.js
CHANGED
|
@@ -36,7 +36,7 @@ const ACTIONS = {
|
|
|
36
36
|
const config = getConfig();
|
|
37
37
|
setNested(config, key, value);
|
|
38
38
|
await saveConfig(config._root, config);
|
|
39
|
-
log('config',
|
|
39
|
+
log('config', `[CFG] Set ${key} = ${value}`);
|
|
40
40
|
return { key, value, saved: true };
|
|
41
41
|
},
|
|
42
42
|
},
|