evolclaw 2.2.0 → 2.4.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.
Files changed (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +283 -95
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +232 -57
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +803 -247
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +217 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
  25. package/dist/index.js +140 -57
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Gemini Agent Runner
3
+ *
4
+ * Integrates Google Gemini CLI as a backend via subprocess.
5
+ * Each runQuery spawns `gemini -p` with --output-format stream-json,
6
+ * parsing the JSONL event stream into EvolClaw AgentEvent.
7
+ *
8
+ * Architecture:
9
+ * GeminiRunner → spawn `gemini -p ...` → stdout JSONL stream
10
+ */
11
+ import { spawn } from 'child_process';
12
+ import { createInterface } from 'readline';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import os from 'os';
16
+ import { resolveGoogleConfig } from '../config.js';
17
+ import { GeminiSessionFileAdapter } from '../core/session/adapters/gemini-session-file-adapter.js';
18
+ import { logger } from '../utils/logger.js';
19
+ // Strip ANSI escape codes from Gemini CLI text output.
20
+ // Gemini embeds raw terminal colors from tool stdout (e.g. vitest, npm)
21
+ // into its assistant text, unlike Claude SDK which strips them internally.
22
+ // eslint-disable-next-line no-control-regex
23
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x1b\\|\x07)/g;
24
+ function stripAnsi(text) {
25
+ return text.replace(ANSI_RE, '');
26
+ }
27
+ // ── MIME → 扩展名映射 ──
28
+ const MIME_EXT = {
29
+ 'image/png': '.png',
30
+ 'image/jpeg': '.jpg',
31
+ 'image/gif': '.gif',
32
+ 'image/webp': '.webp',
33
+ };
34
+ // ── Gemini 模型列表 ──
35
+ const GEMINI_MODELS = [
36
+ 'gemini-2.5-pro',
37
+ 'gemini-2.5-flash',
38
+ 'gemini-2.5-flash-lite',
39
+ ];
40
+ // ── Gemini Runner ──
41
+ export class GeminiRunner {
42
+ name = 'gemini';
43
+ capabilities = { clear: true, compact: false, fork: false };
44
+ resolved;
45
+ model;
46
+ activeProcesses = new Map();
47
+ activeStreams = new Map();
48
+ activeSessions = new Map(); // sessionId → geminiSessionId
49
+ onSessionIdUpdate;
50
+ currentMode = 'auto';
51
+ constructor(config, callbacks) {
52
+ this.resolved = resolveGoogleConfig(config);
53
+ this.model = this.resolved.model;
54
+ this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
55
+ }
56
+ // ── ModelSwitcher ──
57
+ setModel(model) { this.model = model; }
58
+ getModel() { return this.model; }
59
+ listModels() { return GEMINI_MODELS; }
60
+ // ── Effort (not applicable) ──
61
+ setEffort(_effort) { }
62
+ getEffort() { return undefined; }
63
+ // ── Permission ──
64
+ setMode(mode) { this.currentMode = mode; }
65
+ getMode() { return this.currentMode; }
66
+ listModes() {
67
+ return [
68
+ { key: 'auto', nameZh: '自动', description: '全部自动(--yolo 模式)', available: true },
69
+ { key: 'bypass', nameZh: '放行', description: '全部自动(--yolo 模式)', available: true },
70
+ { key: 'edit', nameZh: '编辑', description: '仅 Claude 支持', available: false, unavailableReason: 'Gemini CLI 不支持此模式' },
71
+ { key: 'plan', nameZh: '规划', description: 'Gemini 规划模式', available: true },
72
+ { key: 'noask', nameZh: '静默', description: '仅 Claude 支持', available: false, unavailableReason: 'Gemini CLI 不支持此模式' },
73
+ { key: 'readonly', nameZh: '只读', description: '禁止修改项目文件,可在临时目录生成文件', available: true },
74
+ ];
75
+ }
76
+ setSendPrompt(_fn) { }
77
+ setPermissionGateway(_gw) { }
78
+ // ── Stream management ──
79
+ registerStream(key, stream) {
80
+ this.activeStreams.set(key, stream);
81
+ }
82
+ cleanupStream(key) {
83
+ this.activeStreams.delete(key);
84
+ this.activeProcesses.delete(key);
85
+ }
86
+ hasActiveStream(key) {
87
+ return this.activeStreams.has(key) || this.activeProcesses.has(key);
88
+ }
89
+ // ── Core: runQuery ──
90
+ async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager) {
91
+ let geminiSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
92
+ // Safe mode: skip resume
93
+ if (geminiSessionId && sessionManager) {
94
+ const health = await sessionManager.getHealthStatus(sessionId);
95
+ if (health.safeMode) {
96
+ geminiSessionId = undefined;
97
+ logger.warn(`[GeminiRunner] Safe mode enabled for ${sessionId}, not resuming session`);
98
+ }
99
+ }
100
+ // Build CLI args
101
+ const args = [];
102
+ // Only inject system context on first turn (no resume).
103
+ // Resumed sessions already have the context from the first turn;
104
+ // repeating it pollutes the conversation history.
105
+ let fullPrompt = prompt;
106
+ if (systemPromptAppend && !geminiSessionId) {
107
+ fullPrompt = prompt + '\n\n--- [SYSTEM_PROMPT_END] ---\n' + systemPromptAppend;
108
+ }
109
+ // Handle images: write to temp files, prepend @file references
110
+ const tempFiles = [];
111
+ if (images?.length) {
112
+ const tmpDir = os.tmpdir();
113
+ const fileParts = [];
114
+ for (let i = 0; i < images.length; i++) {
115
+ const img = images[i];
116
+ const ext = MIME_EXT[img.mimeType || ''] || '.jpg';
117
+ const tmpPath = path.join(tmpDir, `evolclaw-gemini-img-${Date.now()}-${i}${ext}`);
118
+ fs.writeFileSync(tmpPath, Buffer.from(img.data, 'base64'));
119
+ tempFiles.push(tmpPath);
120
+ fileParts.push(`@${tmpPath}`);
121
+ }
122
+ fullPrompt = fileParts.join(' ') + ' ' + fullPrompt;
123
+ logger.info(`[GeminiRunner] Attached ${images.length} image(s) via @file reference`);
124
+ }
125
+ args.push('-p', fullPrompt);
126
+ args.push('--output-format', 'stream-json');
127
+ args.push('-m', this.model);
128
+ // Permission mode
129
+ if (this.currentMode === 'plan') {
130
+ args.push('--approval-mode=plan');
131
+ }
132
+ else {
133
+ args.push('--yolo');
134
+ }
135
+ // Resume session
136
+ if (geminiSessionId) {
137
+ args.push('-r', geminiSessionId);
138
+ }
139
+ // Spawn subprocess
140
+ const env = {
141
+ ...process.env,
142
+ };
143
+ if (this.resolved.apiKey) {
144
+ env.GOOGLE_API_KEY = this.resolved.apiKey;
145
+ }
146
+ const child = spawn(this.resolved.cliPath, args, {
147
+ cwd: projectPath,
148
+ stdio: ['pipe', 'pipe', 'pipe'],
149
+ env,
150
+ });
151
+ this.activeProcesses.set(sessionId, child);
152
+ // Log stderr
153
+ child.stderr?.on('data', (data) => {
154
+ const msg = data.toString().trim();
155
+ if (msg)
156
+ logger.debug(`[GeminiRunner:stderr] ${msg}`);
157
+ });
158
+ return this.transformStream(child, sessionId, tempFiles);
159
+ }
160
+ // ── Event stream transformation ──
161
+ async *transformStream(child, sessionId, tempFiles) {
162
+ const pendingToolNames = new Map(); // toolId → toolName
163
+ const startTime = Date.now();
164
+ const rl = createInterface({ input: child.stdout });
165
+ // Build async queue from readline
166
+ const queue = [];
167
+ let resolve = null;
168
+ let rlClosed = false;
169
+ let processExited = false;
170
+ let exitCode = null;
171
+ // We need both rl 'close' AND child 'exit' before considering stream done.
172
+ // rl 'close' guarantees all buffered lines are emitted.
173
+ // child 'exit' gives us the exit code.
174
+ const isStreamDone = () => rlClosed && processExited;
175
+ rl.on('line', (line) => {
176
+ queue.push(line);
177
+ resolve?.();
178
+ });
179
+ rl.on('close', () => {
180
+ rlClosed = true;
181
+ if (isStreamDone())
182
+ resolve?.();
183
+ });
184
+ child.on('exit', (code) => {
185
+ processExited = true;
186
+ exitCode = code;
187
+ if (isStreamDone())
188
+ resolve?.();
189
+ });
190
+ // Handle race: process may have already exited before we registered the listener.
191
+ // Real ChildProcess sets exitCode (number) on exit; null means still running.
192
+ if (!processExited && child.exitCode != null) {
193
+ processExited = true;
194
+ exitCode = child.exitCode;
195
+ }
196
+ child.on('error', (err) => {
197
+ logger.error(`[GeminiRunner] Process error: ${err.message}`);
198
+ rlClosed = true;
199
+ processExited = true;
200
+ resolve?.();
201
+ });
202
+ try {
203
+ let done = false;
204
+ let accumulatedText = '';
205
+ // TextBuffer: accumulate streaming text tokens, flush as a single
206
+ // text event on boundary signals (tool_use / result / error / exit).
207
+ // Prevents StreamFlusher from splitting a single reply into multiple messages.
208
+ let textBuffer = '';
209
+ const flushTextBuffer = function* () {
210
+ if (textBuffer) {
211
+ yield { type: 'text', text: stripAnsi(textBuffer) };
212
+ textBuffer = '';
213
+ }
214
+ };
215
+ while (!done) {
216
+ // Process queued lines
217
+ while (queue.length > 0) {
218
+ const line = queue.shift();
219
+ if (!line.trim())
220
+ continue;
221
+ let event;
222
+ try {
223
+ event = JSON.parse(line);
224
+ }
225
+ catch {
226
+ logger.debug(`[GeminiRunner] Non-JSON line: ${line.substring(0, 200)}`);
227
+ continue;
228
+ }
229
+ switch (event.type) {
230
+ case 'init': {
231
+ // Extract session_id from init event
232
+ const geminiId = event.session_id;
233
+ if (geminiId) {
234
+ this.activeSessions.set(sessionId, geminiId);
235
+ this.onSessionIdUpdate?.(sessionId, geminiId);
236
+ yield { type: 'session_id', sessionId: geminiId };
237
+ }
238
+ break;
239
+ }
240
+ case 'message': {
241
+ // Skip user message echo
242
+ if (event.role === 'user')
243
+ break;
244
+ // Assistant message (delta=true → streaming)
245
+ // Accumulate into textBuffer; will flush on boundary event
246
+ if (event.role === 'assistant' && event.content) {
247
+ accumulatedText += event.content;
248
+ textBuffer += event.content;
249
+ }
250
+ break;
251
+ }
252
+ case 'tool_use': {
253
+ // Boundary: flush accumulated text before tool call
254
+ yield* flushTextBuffer();
255
+ const toolName = event.tool_name || 'unknown';
256
+ if (event.tool_id) {
257
+ pendingToolNames.set(event.tool_id, toolName);
258
+ }
259
+ yield {
260
+ type: 'tool_use',
261
+ name: toolName,
262
+ input: event.parameters || {},
263
+ };
264
+ break;
265
+ }
266
+ case 'tool_result': {
267
+ const toolName = (event.tool_id && pendingToolNames.get(event.tool_id)) || 'unknown';
268
+ if (event.tool_id)
269
+ pendingToolNames.delete(event.tool_id);
270
+ yield {
271
+ type: 'tool_result',
272
+ name: toolName,
273
+ result: stripAnsi(event.output || ''),
274
+ isError: event.status !== 'success',
275
+ };
276
+ break;
277
+ }
278
+ case 'result': {
279
+ // Boundary: flush accumulated text before complete
280
+ yield* flushTextBuffer();
281
+ const durationMs = event.stats?.duration_ms || (Date.now() - startTime);
282
+ const isError = event.status !== 'success';
283
+ // Extract error message from event.error.message (Gemini CLI structure)
284
+ const errorMessage = event.error?.message || event.message;
285
+ yield {
286
+ type: 'complete',
287
+ result: accumulatedText || undefined,
288
+ isError,
289
+ errors: isError ? [errorMessage || event.status || '任务执行失败'] : undefined,
290
+ durationMs,
291
+ costUsd: undefined,
292
+ };
293
+ done = true;
294
+ break;
295
+ }
296
+ case 'error': {
297
+ // Boundary: flush accumulated text before error
298
+ yield* flushTextBuffer();
299
+ yield {
300
+ type: 'error',
301
+ error: event.message || 'Unknown Gemini error',
302
+ errorType: 'unknown',
303
+ };
304
+ if (event.fatal) {
305
+ yield { type: 'complete', result: '', isError: true, durationMs: Date.now() - startTime };
306
+ done = true;
307
+ }
308
+ break;
309
+ }
310
+ default:
311
+ logger.debug(`[GeminiRunner] Unknown event type: ${event.type}`);
312
+ }
313
+ }
314
+ if (!done) {
315
+ if (isStreamDone()) {
316
+ // Boundary: flush accumulated text before exit
317
+ yield* flushTextBuffer();
318
+ // Process exited without result event
319
+ if (exitCode !== 0) {
320
+ yield { type: 'error', error: `Gemini CLI exited with code ${exitCode}`, errorType: 'unknown' };
321
+ }
322
+ yield { type: 'complete', result: '', isError: exitCode !== 0, durationMs: Date.now() - startTime };
323
+ done = true;
324
+ }
325
+ else {
326
+ // Wait for more data
327
+ await new Promise((r) => { resolve = r; });
328
+ resolve = null;
329
+ }
330
+ }
331
+ }
332
+ }
333
+ finally {
334
+ rl.close();
335
+ this.activeProcesses.delete(sessionId);
336
+ // Kill process if still running
337
+ if (!child.killed && !processExited) {
338
+ child.kill('SIGTERM');
339
+ }
340
+ // Cleanup temp image files
341
+ if (tempFiles?.length) {
342
+ for (const f of tempFiles) {
343
+ try {
344
+ fs.unlinkSync(f);
345
+ }
346
+ catch { /* ignore */ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+ // ── Interrupt ──
352
+ async interrupt(sessionKey) {
353
+ const child = this.activeProcesses.get(sessionKey);
354
+ if (child && !child.killed) {
355
+ child.kill('SIGINT');
356
+ setTimeout(() => {
357
+ if (!child.killed) {
358
+ child.kill('SIGTERM');
359
+ logger.info(`[GeminiRunner] SIGTERM fallback for: ${sessionKey}`);
360
+ }
361
+ }, 3000);
362
+ logger.info(`[GeminiRunner] Interrupted session: ${sessionKey} (SIGINT, SIGTERM fallback in 3s)`);
363
+ }
364
+ this.activeProcesses.delete(sessionKey);
365
+ this.activeStreams.delete(sessionKey);
366
+ }
367
+ // ── Session commands ──
368
+ updateSessionId(sessionId, agentSessionId) {
369
+ if (agentSessionId) {
370
+ this.activeSessions.set(sessionId, agentSessionId);
371
+ }
372
+ else {
373
+ this.activeSessions.delete(sessionId);
374
+ }
375
+ this.onSessionIdUpdate?.(sessionId, agentSessionId);
376
+ }
377
+ async closeSession(sessionId) {
378
+ this.activeSessions.delete(sessionId);
379
+ this.activeStreams.delete(sessionId);
380
+ this.activeProcesses.delete(sessionId);
381
+ }
382
+ resolveSessionFile(agentSessionId, projectPath) {
383
+ const adapter = new GeminiSessionFileAdapter();
384
+ return adapter.findSessionFile(projectPath, agentSessionId);
385
+ }
386
+ async clearSession(sessionId, _agentSessionId, _projectPath) {
387
+ // Clear = don't pass -r next time → fresh session
388
+ this.activeSessions.delete(sessionId);
389
+ return true;
390
+ }
391
+ async compactSession(_sessionId, _agentSessionId, _projectPath) {
392
+ logger.info('[GeminiRunner] Compact not supported, Gemini CLI handles context internally');
393
+ return false;
394
+ }
395
+ async compact(_sessionId, _agentSessionId, _projectPath) {
396
+ return this.compactSession(_sessionId, _agentSessionId, _projectPath);
397
+ }
398
+ setCompactStartCallback(_callback) { }
399
+ // ── Cleanup ──
400
+ async dispose() {
401
+ for (const [, child] of this.activeProcesses) {
402
+ if (!child.killed)
403
+ child.kill('SIGTERM');
404
+ }
405
+ this.activeProcesses.clear();
406
+ this.activeStreams.clear();
407
+ this.activeSessions.clear();
408
+ }
409
+ }
410
+ // ── Plugin ──
411
+ export class GeminiAgentPlugin {
412
+ name = 'gemini';
413
+ isEnabled(config) {
414
+ try {
415
+ const resolved = resolveGoogleConfig(config);
416
+ return !!resolved.cliPath;
417
+ }
418
+ catch {
419
+ return false;
420
+ }
421
+ }
422
+ createAgent(config, callbacks) {
423
+ return { agent: new GeminiRunner(config, callbacks) };
424
+ }
425
+ }