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.
- package/CHANGELOG.md +81 -0
- package/package.json +10 -6
- package/src/agent/agent-controller.js +105 -0
- package/src/agent/agent-loop.js +248 -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 +44 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- 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/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +65 -6
- package/src/index.js +151 -29
- package/src/mcp/mcp-handler.js +63 -4
- package/src/mcp/mqtt-client.js +195 -12
- package/src/tools/agent.js +80 -0
- 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/completable-input.js +49 -42
- package/src/tui/ink-app.js +358 -19
- package/src/tui/ink-components.js +82 -12
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextBuilder — Enhanced workspace context (OpenHands-style)
|
|
3
|
+
*
|
|
4
|
+
* Builds rich context about the workspace including:
|
|
5
|
+
* - Git status and history
|
|
6
|
+
* - Project type and structure
|
|
7
|
+
* - Recent files and changes
|
|
8
|
+
* - Dependencies and configuration
|
|
9
|
+
* - Runtime environment
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, readdir, stat } from 'fs/promises';
|
|
12
|
+
import { existsSync } from 'fs';
|
|
13
|
+
import { join, relative, extname } from 'path';
|
|
14
|
+
import { exec } from 'child_process';
|
|
15
|
+
import { getConfig } from '../core/config.js';
|
|
16
|
+
|
|
17
|
+
function run(cmd, cwd, timeout = 5000) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
exec(cmd, { cwd, timeout, maxBuffer: 256 * 1024 }, (_, stdout) => resolve(stdout?.trim() || ''));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ContextBuilder {
|
|
24
|
+
#cwd;
|
|
25
|
+
#cache = new Map();
|
|
26
|
+
#cacheTTL = 30000; // 30 seconds
|
|
27
|
+
|
|
28
|
+
constructor(cwd) {
|
|
29
|
+
this.#cwd = cwd || getConfig().agent?.workspace || process.cwd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build full context
|
|
34
|
+
*/
|
|
35
|
+
async build() {
|
|
36
|
+
const sections = await Promise.all([
|
|
37
|
+
this.#buildGitContext(),
|
|
38
|
+
this.#buildProjectContext(),
|
|
39
|
+
this.#buildRuntimeContext(),
|
|
40
|
+
this.#buildFileSystemContext(),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
return sections.filter(Boolean).join('\n\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build git context
|
|
48
|
+
*/
|
|
49
|
+
async #buildGitContext() {
|
|
50
|
+
if (!existsSync(join(this.#cwd, '.git'))) return null;
|
|
51
|
+
|
|
52
|
+
const cacheKey = 'git';
|
|
53
|
+
const cached = this.#getCache(cacheKey);
|
|
54
|
+
if (cached) return cached;
|
|
55
|
+
|
|
56
|
+
const [branch, status, lastCommits, remotes] = await Promise.all([
|
|
57
|
+
run('git rev-parse --abbrev-ref HEAD', this.#cwd),
|
|
58
|
+
run('git status --porcelain', this.#cwd),
|
|
59
|
+
run('git log --oneline -5', this.#cwd),
|
|
60
|
+
run('git remote -v', this.#cwd),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const changes = status.split('\n').filter(Boolean);
|
|
64
|
+
const changeSummary = changes.length > 0
|
|
65
|
+
? `${changes.length} uncommitted change(s):\n${changes.slice(0, 10).map(c => ` ${c}`).join('\n')}${changes.length > 10 ? `\n ... and ${changes.length - 10} more` : ''}`
|
|
66
|
+
: 'Clean working tree';
|
|
67
|
+
|
|
68
|
+
const sections = [
|
|
69
|
+
`## Git`,
|
|
70
|
+
`Branch: ${branch}`,
|
|
71
|
+
changeSummary,
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
if (lastCommits) {
|
|
75
|
+
sections.push(`Recent commits:\n${lastCommits}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (remotes) {
|
|
79
|
+
const uniqueRemotes = [...new Set(remotes.split('\n').map(r => r.split('\t')[0]).filter(Boolean))];
|
|
80
|
+
if (uniqueRemotes.length > 0) {
|
|
81
|
+
sections.push(`Remotes: ${uniqueRemotes.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = sections.join('\n');
|
|
86
|
+
this.#setCache(cacheKey, result);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build project context
|
|
92
|
+
*/
|
|
93
|
+
async #buildProjectContext() {
|
|
94
|
+
const cacheKey = 'project';
|
|
95
|
+
const cached = this.#getCache(cacheKey);
|
|
96
|
+
if (cached) return cached;
|
|
97
|
+
|
|
98
|
+
const type = await this.#detectProjectType();
|
|
99
|
+
const sections = [`## Project`, `Type: ${type}`];
|
|
100
|
+
|
|
101
|
+
// Package.json (Node.js)
|
|
102
|
+
if (type === 'node' || existsSync(join(this.#cwd, 'package.json'))) {
|
|
103
|
+
try {
|
|
104
|
+
const pkg = JSON.parse(await readFile(join(this.#cwd, 'package.json'), 'utf-8'));
|
|
105
|
+
if (pkg.name) sections.push(`Name: ${pkg.name}@${pkg.version || '?'}`);
|
|
106
|
+
if (pkg.scripts) {
|
|
107
|
+
const scripts = Object.keys(pkg.scripts);
|
|
108
|
+
sections.push(`Scripts: ${scripts.slice(0, 10).join(', ')}${scripts.length > 10 ? '...' : ''}`);
|
|
109
|
+
}
|
|
110
|
+
if (pkg.dependencies) {
|
|
111
|
+
const deps = Object.keys(pkg.dependencies);
|
|
112
|
+
sections.push(`Dependencies: ${deps.length} (${deps.slice(0, 5).join(', ')}${deps.length > 5 ? '...' : ''})`);
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// pyproject.toml (Python)
|
|
118
|
+
if (existsSync(join(this.#cwd, 'pyproject.toml'))) {
|
|
119
|
+
try {
|
|
120
|
+
const content = await readFile(join(this.#cwd, 'pyproject.toml'), 'utf-8');
|
|
121
|
+
const nameMatch = content.match(/name\s*=\s*"([^"]+)"/);
|
|
122
|
+
if (nameMatch) sections.push(`Name: ${nameMatch[1]}`);
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// README
|
|
127
|
+
if (existsSync(join(this.#cwd, 'README.md'))) {
|
|
128
|
+
try {
|
|
129
|
+
const readme = await readFile(join(this.#cwd, 'README.md'), 'utf-8');
|
|
130
|
+
const firstPara = readme.split('\n\n').find(p => p.trim() && !p.startsWith('#'));
|
|
131
|
+
if (firstPara) {
|
|
132
|
+
sections.push(`Description: ${firstPara.trim().slice(0, 200)}`);
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = sections.join('\n');
|
|
138
|
+
this.#setCache(cacheKey, result);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build runtime context
|
|
144
|
+
*/
|
|
145
|
+
async #buildRuntimeContext() {
|
|
146
|
+
const [nodeVer, npmVer, osInfo] = await Promise.all([
|
|
147
|
+
run('node --version', this.#cwd),
|
|
148
|
+
run('npm --version', this.#cwd),
|
|
149
|
+
run('uname -a', this.#cwd),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
return [
|
|
153
|
+
`## Runtime`,
|
|
154
|
+
`Node: ${nodeVer}`,
|
|
155
|
+
`npm: ${npmVer}`,
|
|
156
|
+
`OS: ${osInfo}`,
|
|
157
|
+
`Working Directory: ${this.#cwd}`,
|
|
158
|
+
].join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build file system context (recent files, structure)
|
|
163
|
+
*/
|
|
164
|
+
async #buildFileSystemContext() {
|
|
165
|
+
const cacheKey = 'filesystem';
|
|
166
|
+
const cached = this.#getCache(cacheKey);
|
|
167
|
+
if (cached) return cached;
|
|
168
|
+
|
|
169
|
+
const sections = ['## Workspace Structure'];
|
|
170
|
+
|
|
171
|
+
// Get top-level directory listing
|
|
172
|
+
try {
|
|
173
|
+
const entries = await readdir(this.#cwd, { withFileTypes: true });
|
|
174
|
+
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
|
|
175
|
+
const files = entries.filter(e => e.isFile()).map(e => e.name);
|
|
176
|
+
|
|
177
|
+
if (dirs.length > 0) {
|
|
178
|
+
sections.push(`Directories: ${dirs.slice(0, 15).join(', ')}${dirs.length > 15 ? '...' : ''}`);
|
|
179
|
+
}
|
|
180
|
+
if (files.length > 0) {
|
|
181
|
+
sections.push(`Files: ${files.slice(0, 15).join(', ')}${files.length > 15 ? '...' : ''}`);
|
|
182
|
+
}
|
|
183
|
+
} catch {}
|
|
184
|
+
|
|
185
|
+
// Recently modified files (via git)
|
|
186
|
+
if (existsSync(join(this.#cwd, '.git'))) {
|
|
187
|
+
const recentFiles = await run('git diff --name-only -10', this.#cwd);
|
|
188
|
+
if (recentFiles) {
|
|
189
|
+
sections.push(`Recently modified:\n${recentFiles}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = sections.join('\n');
|
|
194
|
+
this.#setCache(cacheKey, result);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Detect project type
|
|
200
|
+
*/
|
|
201
|
+
async #detectProjectType() {
|
|
202
|
+
const checks = [
|
|
203
|
+
['package.json', 'node'],
|
|
204
|
+
['requirements.txt', 'python'],
|
|
205
|
+
['pyproject.toml', 'python'],
|
|
206
|
+
['Cargo.toml', 'rust'],
|
|
207
|
+
['go.mod', 'go'],
|
|
208
|
+
['CMakeLists.txt', 'cmake'],
|
|
209
|
+
['Makefile', 'make'],
|
|
210
|
+
['pom.xml', 'java'],
|
|
211
|
+
['build.gradle', 'gradle'],
|
|
212
|
+
['Gemfile', 'ruby'],
|
|
213
|
+
['mix.exs', 'elixir'],
|
|
214
|
+
['composer.json', 'php'],
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
for (const [file, type] of checks) {
|
|
218
|
+
if (existsSync(join(this.#cwd, file))) return type;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check for common file types
|
|
222
|
+
try {
|
|
223
|
+
const files = await readdir(this.#cwd);
|
|
224
|
+
const exts = files.map(f => extname(f).toLowerCase());
|
|
225
|
+
if (exts.includes('.py')) return 'python';
|
|
226
|
+
if (exts.includes('.js') || exts.includes('.ts')) return 'node';
|
|
227
|
+
if (exts.includes('.rs')) return 'rust';
|
|
228
|
+
if (exts.includes('.go')) return 'go';
|
|
229
|
+
} catch {}
|
|
230
|
+
|
|
231
|
+
return 'unknown';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get file tree for a specific directory
|
|
236
|
+
*/
|
|
237
|
+
async getFileTree(dir, maxDepth = 3, currentDepth = 0) {
|
|
238
|
+
if (currentDepth >= maxDepth) return [];
|
|
239
|
+
|
|
240
|
+
const entries = [];
|
|
241
|
+
try {
|
|
242
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
if (item.name.startsWith('.') || item.name === 'node_modules') continue;
|
|
245
|
+
|
|
246
|
+
const fullPath = join(dir, item.name);
|
|
247
|
+
const relPath = relative(this.#cwd, fullPath);
|
|
248
|
+
|
|
249
|
+
if (item.isDirectory()) {
|
|
250
|
+
entries.push({ path: relPath, type: 'dir' });
|
|
251
|
+
const children = await this.getFileTree(fullPath, maxDepth, currentDepth + 1);
|
|
252
|
+
entries.push(...children);
|
|
253
|
+
} else {
|
|
254
|
+
const s = await stat(fullPath).catch(() => null);
|
|
255
|
+
entries.push({ path: relPath, type: 'file', size: s?.size || 0 });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {}
|
|
259
|
+
|
|
260
|
+
return entries;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read a specific file with context
|
|
265
|
+
*/
|
|
266
|
+
async readFileWithContext(filePath) {
|
|
267
|
+
const content = await readFile(filePath, 'utf-8');
|
|
268
|
+
const ext = extname(filePath).slice(1);
|
|
269
|
+
const lines = content.split('\n');
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
path: filePath,
|
|
273
|
+
content,
|
|
274
|
+
lines: lines.length,
|
|
275
|
+
language: this.#detectLanguage(ext),
|
|
276
|
+
size: content.length,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#detectLanguage(ext) {
|
|
281
|
+
const map = {
|
|
282
|
+
js: 'javascript', mjs: 'javascript', jsx: 'javascript',
|
|
283
|
+
ts: 'typescript', tsx: 'typescript',
|
|
284
|
+
py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
|
|
285
|
+
java: 'java', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
|
|
286
|
+
sh: 'shell', bash: 'shell', zsh: 'shell',
|
|
287
|
+
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
|
|
288
|
+
md: 'markdown', html: 'html', css: 'css', sql: 'sql',
|
|
289
|
+
};
|
|
290
|
+
return map[ext] || 'unknown';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Simple cache
|
|
294
|
+
#getCache(key) {
|
|
295
|
+
const entry = this.#cache.get(key);
|
|
296
|
+
if (entry && Date.now() - entry.time < this.#cacheTTL) return entry.value;
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#setCache(key, value) {
|
|
301
|
+
this.#cache.set(key, { value, time: Date.now() });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
clearCache() {
|
|
305
|
+
this.#cache.clear();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HistoryCondenser — Manage long conversations (OpenHands-style)
|
|
3
|
+
*
|
|
4
|
+
* When history gets too long, condense it by:
|
|
5
|
+
* 1. Summarizing old messages
|
|
6
|
+
* 2. Keeping recent messages intact
|
|
7
|
+
* 3. Preserving important context (files read, errors, decisions)
|
|
8
|
+
*/
|
|
9
|
+
import { debug } from '../core/logger.js';
|
|
10
|
+
|
|
11
|
+
export class HistoryCondenser {
|
|
12
|
+
#maxMessages;
|
|
13
|
+
#maxTokens;
|
|
14
|
+
#condensedHistory = [];
|
|
15
|
+
|
|
16
|
+
constructor({ maxMessages = 50, maxTokens = 100000 } = {}) {
|
|
17
|
+
this.#maxMessages = maxMessages;
|
|
18
|
+
this.#maxTokens = maxTokens;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if history needs condensation
|
|
23
|
+
*/
|
|
24
|
+
needsCondensation(messages) {
|
|
25
|
+
if (messages.length > this.#maxMessages) return true;
|
|
26
|
+
const estimatedTokens = this.#estimateTokens(messages);
|
|
27
|
+
if (estimatedTokens > this.#maxTokens) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Condense history to fit within limits
|
|
33
|
+
* @param {Array} messages - Full message history
|
|
34
|
+
* @param {object} llm - LLM client for summarization (optional)
|
|
35
|
+
* @returns {Array} - Condensed messages
|
|
36
|
+
*/
|
|
37
|
+
async condense(messages, llm = null) {
|
|
38
|
+
if (!this.needsCondensation(messages)) return messages;
|
|
39
|
+
|
|
40
|
+
debug('history', `Condensing ${messages.length} messages`);
|
|
41
|
+
|
|
42
|
+
// Strategy 1: Keep first message + last N messages
|
|
43
|
+
const keepRecent = Math.floor(this.#maxMessages * 0.7);
|
|
44
|
+
const firstMessage = messages[0];
|
|
45
|
+
const recentMessages = messages.slice(-keepRecent);
|
|
46
|
+
|
|
47
|
+
// Strategy 2: Summarize middle section if LLM available
|
|
48
|
+
if (llm) {
|
|
49
|
+
const middleMessages = messages.slice(1, -keepRecent);
|
|
50
|
+
const summary = await this.#summarize(middleMessages, llm);
|
|
51
|
+
|
|
52
|
+
if (summary) {
|
|
53
|
+
return [
|
|
54
|
+
firstMessage,
|
|
55
|
+
{ role: 'system', content: `[Previous conversation summary]: ${summary}` },
|
|
56
|
+
...recentMessages,
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fallback: Just keep first + recent
|
|
62
|
+
return [firstMessage, ...recentMessages];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Summarize a batch of messages using LLM
|
|
67
|
+
*/
|
|
68
|
+
async #summarize(messages, llm) {
|
|
69
|
+
try {
|
|
70
|
+
const conversation = messages.map(m => {
|
|
71
|
+
if (m.role === 'user') return `User: ${m.content}`;
|
|
72
|
+
if (m.role === 'assistant') return `Assistant: ${m.content || '(tool call)'}`;
|
|
73
|
+
if (m.role === 'tool') return `Tool (${m.name}): ${typeof m.content === 'string' ? m.content.slice(0, 200) : '(result)'}`;
|
|
74
|
+
return '';
|
|
75
|
+
}).filter(Boolean).join('\n');
|
|
76
|
+
|
|
77
|
+
const response = await llm.chat([
|
|
78
|
+
{ role: 'system', content: 'Summarize this conversation concisely. Focus on: files modified, decisions made, errors encountered, and current task status. Max 200 words.' },
|
|
79
|
+
{ role: 'user', content: conversation },
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
let content = response.content;
|
|
83
|
+
if (Array.isArray(content)) {
|
|
84
|
+
content = content.filter(c => c.type === 'text').map(c => c.text).join('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return content;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
debug('history', `Summarization failed: ${err.message}`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract important context from messages
|
|
96
|
+
* This context is preserved even when messages are condensed
|
|
97
|
+
*/
|
|
98
|
+
extractImportantContext(messages) {
|
|
99
|
+
const context = {
|
|
100
|
+
filesRead: new Set(),
|
|
101
|
+
filesModified: new Set(),
|
|
102
|
+
errors: [],
|
|
103
|
+
decisions: [],
|
|
104
|
+
toolCalls: [],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const msg of messages) {
|
|
108
|
+
// Track file operations
|
|
109
|
+
if (msg.role === 'tool' && msg.name === 'file') {
|
|
110
|
+
try {
|
|
111
|
+
const result = JSON.parse(msg.content);
|
|
112
|
+
if (result.path) {
|
|
113
|
+
if (msg.args?.action === 'read') context.filesRead.add(result.path);
|
|
114
|
+
if (msg.args?.action === 'write' || msg.args?.action === 'edit') {
|
|
115
|
+
context.filesModified.add(result.path);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Track errors
|
|
122
|
+
if (msg.role === 'tool' && msg.content?.includes('error')) {
|
|
123
|
+
context.errors.push(msg.content.slice(0, 200));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Track tool calls
|
|
127
|
+
if (msg.role === 'tool') {
|
|
128
|
+
context.toolCalls.push({
|
|
129
|
+
name: msg.name,
|
|
130
|
+
timestamp: msg.timestamp,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
filesRead: [...context.filesRead],
|
|
137
|
+
filesModified: [...context.filesModified],
|
|
138
|
+
recentErrors: context.errors.slice(-5),
|
|
139
|
+
totalToolCalls: context.toolCalls.length,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Estimate token count (rough approximation)
|
|
145
|
+
*/
|
|
146
|
+
#estimateTokens(messages) {
|
|
147
|
+
let totalChars = 0;
|
|
148
|
+
for (const msg of messages) {
|
|
149
|
+
if (typeof msg.content === 'string') {
|
|
150
|
+
totalChars += msg.content.length;
|
|
151
|
+
}
|
|
152
|
+
if (msg.tool_calls) {
|
|
153
|
+
totalChars += JSON.stringify(msg.tool_calls).length;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Rough estimate: ~4 chars per token
|
|
157
|
+
return Math.ceil(totalChars / 4);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build context summary for condensed history
|
|
162
|
+
*/
|
|
163
|
+
buildContextSummary(messages) {
|
|
164
|
+
const context = this.extractImportantContext(messages);
|
|
165
|
+
const parts = [];
|
|
166
|
+
|
|
167
|
+
if (context.filesRead.length > 0) {
|
|
168
|
+
parts.push(`Files read: ${context.filesRead.join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
if (context.filesModified.length > 0) {
|
|
171
|
+
parts.push(`Files modified: ${context.filesModified.join(', ')}`);
|
|
172
|
+
}
|
|
173
|
+
if (context.recentErrors.length > 0) {
|
|
174
|
+
parts.push(`Recent errors:\n${context.recentErrors.map(e => ` - ${e}`).join('\n')}`);
|
|
175
|
+
}
|
|
176
|
+
parts.push(`Total tool calls: ${context.totalToolCalls}`);
|
|
177
|
+
|
|
178
|
+
return parts.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get optimal message window for a given token budget
|
|
183
|
+
*/
|
|
184
|
+
getWindow(messages, tokenBudget) {
|
|
185
|
+
let currentTokens = 0;
|
|
186
|
+
let endIndex = messages.length;
|
|
187
|
+
|
|
188
|
+
// Walk backwards from the end
|
|
189
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
190
|
+
const msgTokens = this.#estimateTokens([messages[i]]);
|
|
191
|
+
if (currentTokens + msgTokens > tokenBudget) break;
|
|
192
|
+
currentTokens += msgTokens;
|
|
193
|
+
endIndex = i;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Always include the first message (user's original request)
|
|
197
|
+
if (endIndex > 1) {
|
|
198
|
+
return [messages[0], ...messages.slice(endIndex)];
|
|
199
|
+
}
|
|
200
|
+
return messages.slice(endIndex);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Module — OpenHands-style agent system for Pelulu-CLI
|
|
3
|
+
*/
|
|
4
|
+
export { AgentLoop, AgentState } from './agent-loop.js';
|
|
5
|
+
export { LLMClient } from './llm-client.js';
|
|
6
|
+
export { ContextBuilder } from './context-builder.js';
|
|
7
|
+
export { AgentController } from './agent-controller.js';
|
|
8
|
+
export { buildSystemPrompt, matchMicroagents } from './system-prompt.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLMClient — XiaoZhi AI MQTT wrapper
|
|
3
|
+
*
|
|
4
|
+
* Sends user messages to XiaoZhi via MQTT.
|
|
5
|
+
* Response handling is done by AgentLoop via bus events.
|
|
6
|
+
*/
|
|
7
|
+
import { debug } from '../core/logger.js';
|
|
8
|
+
|
|
9
|
+
const MAX_PROMPT_LEN = 70;
|
|
10
|
+
|
|
11
|
+
export class LLMClient {
|
|
12
|
+
#mqtt;
|
|
13
|
+
|
|
14
|
+
constructor(mqtt) {
|
|
15
|
+
this.#mqtt = mqtt;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wait for MCP handshake to complete (tools received + session assigned)
|
|
20
|
+
*/
|
|
21
|
+
async #waitForReady() {
|
|
22
|
+
if (this.#mqtt.mcp?.toolsReceived && this.#mqtt.sessionId) return;
|
|
23
|
+
debug('llm', 'Session not ready — re-establishing...');
|
|
24
|
+
// Actively re-send hello to recover from an idle `goodbye`, rather than
|
|
25
|
+
// polling for a session that will never come back on its own.
|
|
26
|
+
const ok = await this.#mqtt.ensureSession(30000);
|
|
27
|
+
if (!ok) throw new Error('MCP handshake timeout');
|
|
28
|
+
debug('llm', 'MCP ready');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send prompt to XiaoZhi (waits for MCP handshake first)
|
|
33
|
+
* Response comes back via llm:text or mcp:tool_call events
|
|
34
|
+
*/
|
|
35
|
+
async sendPrompt(prompt) {
|
|
36
|
+
if (prompt.length > MAX_PROMPT_LEN) {
|
|
37
|
+
debug('llm', `Prompt too long: ${prompt.length} > ${MAX_PROMPT_LEN}`);
|
|
38
|
+
throw new Error(`Prompt too long (${prompt.length}/${MAX_PROMPT_LEN} chars)`);
|
|
39
|
+
}
|
|
40
|
+
await this.#waitForReady();
|
|
41
|
+
debug('llm', `Sending: ${prompt}`);
|
|
42
|
+
await this.#mqtt.sendText(prompt);
|
|
43
|
+
}
|
|
44
|
+
}
|