evolclaw 2.0.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,352 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import readline from 'readline';
5
+ import { createRequire } from 'module';
6
+ import { execFileSync } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import { execFile } from 'child_process';
9
+ import { resolveRoot, resolvePaths, ensureDataDirs, getPackageRoot } from '../paths.js';
10
+ const execFileAsync = promisify(execFile);
11
+ // ==================== Helpers ====================
12
+ function ask(rl, question) {
13
+ return new Promise(resolve => rl.question(question, resolve));
14
+ }
15
+ async function npmInstallGlobal(pkg) {
16
+ try {
17
+ await execFileAsync('npm', ['install', '-g', pkg], { timeout: 120000 });
18
+ }
19
+ catch (e) {
20
+ if (e.stderr?.includes('EACCES') || e.message?.includes('EACCES')) {
21
+ await execFileAsync('sudo', ['npm', 'install', '-g', pkg], { timeout: 120000 });
22
+ }
23
+ else {
24
+ throw e;
25
+ }
26
+ }
27
+ }
28
+ async function sudoExec(cmd, args) {
29
+ try {
30
+ await execFileAsync(cmd, args, { timeout: 120000 });
31
+ }
32
+ catch (e) {
33
+ if (e.stderr?.includes('EACCES') || e.message?.includes('EACCES') || e.code === 'EACCES') {
34
+ await execFileAsync('sudo', [cmd, ...args], { timeout: 120000 });
35
+ }
36
+ else {
37
+ throw e;
38
+ }
39
+ }
40
+ }
41
+ // ==================== Environment Check ====================
42
+ async function checkEnvironment(rl) {
43
+ console.log('🔍 环境检查...\n');
44
+ // Node.js >= 22
45
+ const nodeVer = parseInt(process.versions.node.split('.')[0], 10);
46
+ if (nodeVer >= 22) {
47
+ console.log(` ✓ Node.js v${process.versions.node}`);
48
+ }
49
+ else {
50
+ console.log(` ✗ Node.js v${process.versions.node} — 需要 >= 22(node:sqlite 依赖)`);
51
+ // 检测 nvm
52
+ const hasNvm = !!process.env.NVM_DIR && fs.existsSync(process.env.NVM_DIR);
53
+ if (hasNvm) {
54
+ const answer = (await ask(rl, ' → 是否通过 nvm 升级到 Node.js 22?[Y/n] ')).trim().toLowerCase();
55
+ if (answer === 'n' || answer === 'no') {
56
+ console.log(' 已取消');
57
+ return false;
58
+ }
59
+ console.log(' 正在升级 Node.js...');
60
+ try {
61
+ const nvmDir = process.env.NVM_DIR;
62
+ const { stdout } = await execFileAsync('bash', ['-c', `source "${nvmDir}/nvm.sh" && nvm install 22 && nvm alias default 22`], { timeout: 120000 });
63
+ console.log(stdout.trim().split('\n').map(l => ` ${l}`).join('\n'));
64
+ console.log(' ✓ Node.js 升级完成,请重新运行 evolclaw init');
65
+ return false;
66
+ }
67
+ catch (e) {
68
+ console.log(` ✗ 升级失败: ${e.message?.slice(0, 200) || e}`);
69
+ return false;
70
+ }
71
+ }
72
+ else {
73
+ // 检测 n
74
+ let hasN = false;
75
+ try {
76
+ execFileSync('which', ['n'], { encoding: 'utf-8' });
77
+ hasN = true;
78
+ }
79
+ catch { }
80
+ if (hasN) {
81
+ const answer = (await ask(rl, ' → 是否通过 n 升级到 Node.js 22?[Y/n] ')).trim().toLowerCase();
82
+ if (answer === 'n' || answer === 'no') {
83
+ console.log(' 已取消');
84
+ return false;
85
+ }
86
+ console.log(' 正在升级 Node.js...');
87
+ try {
88
+ await sudoExec('n', ['22']);
89
+ console.log(' ✓ Node.js 升级完成,请重新运行 evolclaw init');
90
+ return false;
91
+ }
92
+ catch (e) {
93
+ console.log(` ✗ 升级失败: ${e.message?.slice(0, 200) || e}`);
94
+ return false;
95
+ }
96
+ }
97
+ // 无版本管理器,用 npm 安装 n 再升级
98
+ const answer = (await ask(rl, ' → 是否通过 npm 安装 n 并升级到 Node.js 22?[Y/n] ')).trim().toLowerCase();
99
+ if (answer === 'n' || answer === 'no') {
100
+ console.log(' 已取消');
101
+ return false;
102
+ }
103
+ console.log(' 正在安装 n...');
104
+ try {
105
+ await npmInstallGlobal('n');
106
+ console.log(' 正在升级 Node.js...');
107
+ await sudoExec('n', ['22']);
108
+ console.log(' ✓ Node.js 升级完成,请重新运行 evolclaw init');
109
+ return false;
110
+ }
111
+ catch (e) {
112
+ console.log(` ✗ 升级失败: ${e.message?.slice(0, 200) || e}`);
113
+ return false;
114
+ }
115
+ }
116
+ }
117
+ // claude CLI >= 2.1.32
118
+ const MIN_CLAUDE_VER = [2, 1, 32];
119
+ let claudeInstalled = false;
120
+ try {
121
+ execFileSync('which', ['claude'], { encoding: 'utf-8' });
122
+ claudeInstalled = true;
123
+ const verOutput = execFileSync('claude', ['--version'], { encoding: 'utf-8' }).trim();
124
+ const verMatch = verOutput.match(/^(\d+\.\d+\.\d+)/);
125
+ if (verMatch) {
126
+ const parts = verMatch[1].split('.').map(Number);
127
+ const isOk = parts[0] > MIN_CLAUDE_VER[0]
128
+ || (parts[0] === MIN_CLAUDE_VER[0] && parts[1] > MIN_CLAUDE_VER[1])
129
+ || (parts[0] === MIN_CLAUDE_VER[0] && parts[1] === MIN_CLAUDE_VER[1] && parts[2] >= MIN_CLAUDE_VER[2]);
130
+ if (isOk) {
131
+ console.log(` ✓ claude CLI v${verMatch[1]}`);
132
+ }
133
+ else {
134
+ console.log(` ✗ claude CLI v${verMatch[1]} — 需要 >= ${MIN_CLAUDE_VER.join('.')}`);
135
+ const answer = (await ask(rl, ' → 是否升级 claude CLI?[Y/n] ')).trim().toLowerCase();
136
+ if (answer === 'n' || answer === 'no') {
137
+ console.log(' 已取消');
138
+ return false;
139
+ }
140
+ console.log(' 正在升级 claude CLI...');
141
+ try {
142
+ await npmInstallGlobal('@anthropic-ai/claude-code@latest');
143
+ console.log(' ✓ claude CLI 升级完成');
144
+ }
145
+ catch (e) {
146
+ console.log(` ✗ 升级失败: ${e.message?.slice(0, 200) || e}`);
147
+ return false;
148
+ }
149
+ }
150
+ }
151
+ else {
152
+ console.log(` ✓ claude CLI (${verOutput})`);
153
+ }
154
+ }
155
+ catch {
156
+ if (!claudeInstalled) {
157
+ console.log(' ✗ claude CLI 未找到');
158
+ console.log(' → 请先安装: npm install -g @anthropic-ai/claude-code');
159
+ return false;
160
+ }
161
+ }
162
+ // @anthropic-ai/claude-agent-sdk >= 0.2.75
163
+ let sdkAction = 'ok';
164
+ try {
165
+ let sdkPkgPath = null;
166
+ const esmRequire = createRequire(import.meta.url);
167
+ try {
168
+ sdkPkgPath = esmRequire.resolve('@anthropic-ai/claude-agent-sdk/package.json');
169
+ }
170
+ catch {
171
+ try {
172
+ const globalRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf-8' }).trim();
173
+ const globalPath = path.join(globalRoot, '@anthropic-ai', 'claude-agent-sdk', 'package.json');
174
+ if (fs.existsSync(globalPath))
175
+ sdkPkgPath = globalPath;
176
+ }
177
+ catch { }
178
+ }
179
+ if (!sdkPkgPath)
180
+ throw new Error('not found');
181
+ const sdkPkg = JSON.parse(fs.readFileSync(sdkPkgPath, 'utf-8'));
182
+ const sdkVer = sdkPkg.version;
183
+ const parts = sdkVer.split('.').map(Number);
184
+ const sdkOk = parts[0] > 0 || parts[1] > 2 || (parts[1] === 2 && parts[2] >= 75);
185
+ if (sdkOk) {
186
+ console.log(` ✓ claude-agent-sdk v${sdkVer}`);
187
+ }
188
+ else {
189
+ console.log(` ✗ claude-agent-sdk v${sdkVer} — 需要 >= 0.2.75`);
190
+ sdkAction = 'upgrade';
191
+ }
192
+ }
193
+ catch {
194
+ console.log(' ✗ claude-agent-sdk 未安装');
195
+ sdkAction = 'install';
196
+ }
197
+ if (sdkAction !== 'ok') {
198
+ const verb = sdkAction === 'install' ? '安装' : '升级';
199
+ const answer = (await ask(rl, ` → 是否${verb} claude-agent-sdk?[Y/n] `)).trim().toLowerCase();
200
+ if (answer === 'n' || answer === 'no') {
201
+ console.log(' 已取消');
202
+ return false;
203
+ }
204
+ console.log(` 正在${verb} claude-agent-sdk...`);
205
+ try {
206
+ await npmInstallGlobal('@anthropic-ai/claude-agent-sdk@latest');
207
+ console.log(` ✓ claude-agent-sdk ${verb}完成`);
208
+ }
209
+ catch (e) {
210
+ console.log(` ✗ ${verb}失败: ${e.message?.slice(0, 200) || e}`);
211
+ return false;
212
+ }
213
+ }
214
+ console.log('');
215
+ return true;
216
+ }
217
+ // ==================== Shell Profile ====================
218
+ function setupEnvVar(home) {
219
+ const exportLine = `export EVOLCLAW_HOME="${home}"`;
220
+ const candidates = [
221
+ path.join(os.homedir(), '.zshrc'),
222
+ path.join(os.homedir(), '.bashrc'),
223
+ path.join(os.homedir(), '.bash_profile'),
224
+ ];
225
+ let written = false;
226
+ for (const profilePath of candidates) {
227
+ if (!fs.existsSync(profilePath))
228
+ continue;
229
+ const content = fs.readFileSync(profilePath, 'utf-8');
230
+ if (content.includes('EVOLCLAW_HOME')) {
231
+ console.log(` ✓ EVOLCLAW_HOME 已在 ${profilePath} 中配置`);
232
+ written = true;
233
+ continue;
234
+ }
235
+ fs.appendFileSync(profilePath, `\n# EvolClaw\n${exportLine}\n`);
236
+ console.log(` ✓ 已写入 ${profilePath}: ${exportLine}`);
237
+ written = true;
238
+ }
239
+ if (!written) {
240
+ const shell = process.env.SHELL || '/bin/bash';
241
+ const profilePath = shell.endsWith('zsh')
242
+ ? path.join(os.homedir(), '.zshrc')
243
+ : path.join(os.homedir(), '.bashrc');
244
+ fs.appendFileSync(profilePath, `\n# EvolClaw\n${exportLine}\n`);
245
+ console.log(` ✓ 已写入 ${profilePath}: ${exportLine}`);
246
+ }
247
+ console.log(' ⚠ 请重新打开终端或执行 source 使其生效');
248
+ }
249
+ // ==================== Main ====================
250
+ export async function cmdInit() {
251
+ const p = resolvePaths();
252
+ ensureDataDirs();
253
+ // 检查服务是否在运行
254
+ if (fs.existsSync(p.pid)) {
255
+ const pid = parseInt(fs.readFileSync(p.pid, 'utf-8').trim(), 10);
256
+ try {
257
+ process.kill(pid, 0);
258
+ console.log(`❌ EvolClaw 正在运行 (PID: ${pid}),请先执行 evolclaw stop`);
259
+ return;
260
+ }
261
+ catch { }
262
+ }
263
+ const sampleSrc = path.join(getPackageRoot(), 'data', 'evolclaw.sample.json');
264
+ if (!fs.existsSync(sampleSrc)) {
265
+ console.log(`❌ 找不到示例配置: ${sampleSrc}`);
266
+ return;
267
+ }
268
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
269
+ try {
270
+ if (fs.existsSync(p.config)) {
271
+ const answer = (await ask(rl, `配置文件已存在: ${p.config}\n 是否重新初始化?[y/N] `)).trim().toLowerCase();
272
+ if (answer !== 'y' && answer !== 'yes') {
273
+ console.log(' 已取消');
274
+ return;
275
+ }
276
+ }
277
+ if (!await checkEnvironment(rl)) {
278
+ return;
279
+ }
280
+ console.log('📝 交互式配置\n');
281
+ // feishu.appId
282
+ let appId = '';
283
+ while (!appId) {
284
+ appId = (await ask(rl, ' 飞书 App ID: ')).trim();
285
+ if (!appId)
286
+ console.log(' ⚠ 不能为空');
287
+ }
288
+ // feishu.appSecret
289
+ let appSecret = '';
290
+ while (!appSecret) {
291
+ appSecret = (await ask(rl, ' 飞书 App Secret: ')).trim();
292
+ if (!appSecret)
293
+ console.log(' ⚠ 不能为空');
294
+ }
295
+ // 验证飞书凭证
296
+ console.log(' 正在验证飞书凭证...');
297
+ try {
298
+ const lark = await import('@larksuiteoapi/node-sdk');
299
+ const client = new lark.Client({ appId, appSecret });
300
+ const res = await client.auth.tenantAccessToken.internal({
301
+ data: { app_id: appId, app_secret: appSecret },
302
+ });
303
+ if (res.code === 0) {
304
+ console.log(' ✓ 飞书凭证验证通过');
305
+ }
306
+ else {
307
+ console.log(` ✗ 飞书凭证验证失败: ${res.msg}`);
308
+ const answer = (await ask(rl, ' → 是否继续?[y/N] ')).trim().toLowerCase();
309
+ if (answer !== 'y' && answer !== 'yes') {
310
+ console.log(' 已取消');
311
+ return;
312
+ }
313
+ }
314
+ }
315
+ catch (e) {
316
+ console.log(` ⚠ 飞书凭证验证跳过: ${e.message?.slice(0, 100) || e}`);
317
+ }
318
+ // projects.defaultPath
319
+ const defaultSuggestion = path.join(os.homedir(), 'evolclaw-project');
320
+ let defaultPath = (await ask(rl, ` 默认项目路径 [${defaultSuggestion}]: `)).trim();
321
+ if (!defaultPath) {
322
+ defaultPath = defaultSuggestion;
323
+ }
324
+ if (defaultPath.startsWith('~/')) {
325
+ defaultPath = path.join(os.homedir(), defaultPath.slice(2));
326
+ }
327
+ else if (defaultPath === '~') {
328
+ defaultPath = os.homedir();
329
+ }
330
+ if (!fs.existsSync(defaultPath)) {
331
+ fs.mkdirSync(defaultPath, { recursive: true });
332
+ console.log(` ✓ 已创建目录: ${defaultPath}`);
333
+ }
334
+ // anthropic.model
335
+ const modelInput = (await ask(rl, ' 模型 [sonnet(默认)/opus/haiku]: ')).trim().toLowerCase();
336
+ const model = ['opus', 'haiku'].includes(modelInput) ? modelInput : 'sonnet';
337
+ // Generate config
338
+ const config = JSON.parse(fs.readFileSync(sampleSrc, 'utf-8'));
339
+ config.feishu.appId = appId;
340
+ config.feishu.appSecret = appSecret;
341
+ config.projects.defaultPath = defaultPath;
342
+ config.projects.list = { [path.basename(defaultPath)]: defaultPath };
343
+ config.anthropic.model = model;
344
+ fs.writeFileSync(p.config, JSON.stringify(config, null, 2) + '\n');
345
+ console.log(`\n✓ 已创建配置文件: ${p.config}`);
346
+ // Setup EVOLCLAW_HOME in shell profile
347
+ setupEnvVar(resolveRoot());
348
+ }
349
+ finally {
350
+ rl.close();
351
+ }
352
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { resolvePaths } from '../paths.js';
4
+ const LOG_DIR = resolvePaths().logs;
5
+ const LOG_LEVEL = process.env.LOG_LEVEL || 'INFO';
6
+ const LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
7
+ const config = {
8
+ messageLog: process.env.MESSAGE_LOG === 'true',
9
+ eventLog: process.env.EVENT_LOG === 'true'
10
+ };
11
+ if (!fs.existsSync(LOG_DIR)) {
12
+ fs.mkdirSync(LOG_DIR, { recursive: true });
13
+ }
14
+ const streams = {
15
+ main: fs.createWriteStream(path.join(LOG_DIR, 'evolclaw.log'), { flags: 'a' }),
16
+ message: config.messageLog ? fs.createWriteStream(path.join(LOG_DIR, 'messages.log'), { flags: 'a' }) : null,
17
+ event: config.eventLog ? fs.createWriteStream(path.join(LOG_DIR, 'events.log'), { flags: 'a' }) : null
18
+ };
19
+ function shouldLog(level) {
20
+ return LEVELS[level] >= LEVELS[LOG_LEVEL];
21
+ }
22
+ function write(stream, data) {
23
+ if (!stream)
24
+ return;
25
+ const line = typeof data === 'string' ? data : JSON.stringify(data);
26
+ stream.write(`${line}\n`);
27
+ }
28
+ function log(level, ...args) {
29
+ if (!shouldLog(level))
30
+ return;
31
+ const timestamp = new Date().toISOString();
32
+ const msg = `[${timestamp}] [${level}] ${args.join(' ')}`;
33
+ // 只写文件,不输出到 console(避免重定向时重复)
34
+ write(streams.main, msg);
35
+ }
36
+ export const logger = {
37
+ debug: (...args) => log('DEBUG', ...args),
38
+ info: (...args) => log('INFO', ...args),
39
+ warn: (...args) => log('WARN', ...args),
40
+ error: (...args) => log('ERROR', ...args),
41
+ message: (data) => {
42
+ write(streams.message, { ts: new Date().toISOString(), ...data });
43
+ },
44
+ event: (data) => {
45
+ write(streams.event, { ts: new Date().toISOString(), ...data });
46
+ }
47
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Markdown 到飞书富文本格式转换工具
3
+ * 使用飞书 post 格式的 md tag 原生渲染 Markdown
4
+ */
5
+ /**
6
+ * 将 Markdown 文本转换为飞书 post 消息格式
7
+ * 利用 md tag 让飞书原生渲染,支持代码高亮、嵌套列表、引用等全部语法
8
+ */
9
+ export function markdownToFeishuPost(markdown, defaultTitle) {
10
+ const match = markdown.match(/^# (.+)$/m);
11
+ const title = match?.[1] ?? defaultTitle ?? '';
12
+ const body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
13
+ return {
14
+ zh_cn: {
15
+ title,
16
+ content: [[{ tag: 'md', text: body.trim() }]]
17
+ }
18
+ };
19
+ }
20
+ /**
21
+ * 检测文本是否包含 Markdown 语法
22
+ */
23
+ export function hasMarkdownSyntax(text) {
24
+ const markdownPatterns = [
25
+ /^#{1,6}\s/m, // 标题
26
+ /\*\*.*?\*\*/, // 粗体
27
+ /\*.*?\*/, // 斜体
28
+ /__.*?__/, // 粗体
29
+ /_.*?_/, // 斜体
30
+ /~~.*?~~/, // 删除线
31
+ /`.*?`/, // 行内代码
32
+ /```[\s\S]*?```/, // 代码块
33
+ /\[.*?\]\(.*?\)/, // 链接
34
+ /^[\s]*[-*+]\s/m, // 无序列表
35
+ /^[\s]*\d+\.\s/m // 有序列表
36
+ ];
37
+ return markdownPatterns.some(pattern => pattern.test(text));
38
+ }
@@ -0,0 +1,36 @@
1
+ // 危险命令黑名单(正则表达式)
2
+ const DANGEROUS_PATTERNS = [
3
+ /\brm\s+-\w*r\w*f/, // rm -rf
4
+ /\bsudo\b/, // sudo
5
+ /\bmkfs\b/, // mkfs (格式化文件系统)
6
+ /\bdd\s+if=/, // dd (磁盘操作)
7
+ /\bchmod\s+777/, // chmod 777 (危险权限)
8
+ />\s*\/dev\//, // 重定向到设备文件
9
+ /\bshutdown\b/, // 关机
10
+ /\breboot\b/, // 重启
11
+ ];
12
+ /**
13
+ * 权限检查回调函数
14
+ * 符合 Claude Agent SDK 的 can_use_tool 接口
15
+ */
16
+ export async function canUseTool(toolName, input) {
17
+ // 只检查 Bash 工具,其余工具全部放行
18
+ if (toolName === 'Bash') {
19
+ const cmd = input.command || '';
20
+ // 空命令直接放行
21
+ if (!cmd || cmd.trim() === '') {
22
+ return { behavior: 'allow', updatedInput: input };
23
+ }
24
+ // 检查黑名单
25
+ for (const pattern of DANGEROUS_PATTERNS) {
26
+ if (pattern.test(cmd)) {
27
+ return {
28
+ behavior: 'deny',
29
+ message: `⛔ 危险命令被拦截: ${cmd.substring(0, 80)}`
30
+ };
31
+ }
32
+ }
33
+ }
34
+ // 默认允许
35
+ return { behavior: 'allow', updatedInput: input };
36
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { logger } from './logger.js';
4
+ /**
5
+ * 检查会话文件是否存在
6
+ */
7
+ async function fileExists(filePath) {
8
+ try {
9
+ await fs.access(filePath);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * 检查会话文件健康度
18
+ */
19
+ export async function checkSessionFileHealth(projectPath, claudeSessionId) {
20
+ const issues = [];
21
+ const sessionFile = path.join(projectPath, '.claude', `${claudeSessionId}.jsonl`);
22
+ // 检查文件是否存在
23
+ if (!(await fileExists(sessionFile))) {
24
+ // 新会话没有文件是正常的
25
+ return { healthy: true, issues: [] };
26
+ }
27
+ try {
28
+ // 检查文件大小
29
+ const stats = await fs.stat(sessionFile);
30
+ const sizeMB = stats.size / (1024 * 1024);
31
+ if (stats.size > 50 * 1024 * 1024) {
32
+ issues.push(`会话文件过大: ${sizeMB.toFixed(1)}MB`);
33
+ }
34
+ // 检查 JSON 格式
35
+ const content = await fs.readFile(sessionFile, 'utf-8');
36
+ const lines = content.split('\n').filter(l => l.trim());
37
+ for (let i = 0; i < lines.length; i++) {
38
+ try {
39
+ JSON.parse(lines[i]);
40
+ }
41
+ catch (e) {
42
+ issues.push(`会话文件格式损坏(第 ${i + 1} 行)`);
43
+ return { healthy: false, issues, corrupt: true, fileSize: stats.size };
44
+ }
45
+ }
46
+ return {
47
+ healthy: issues.length === 0,
48
+ issues,
49
+ fileSize: stats.size
50
+ };
51
+ }
52
+ catch (error) {
53
+ logger.error('[SessionFileHealth] Check failed:', error);
54
+ issues.push(`文件读取失败: ${error.message}`);
55
+ return { healthy: false, issues, corrupt: true };
56
+ }
57
+ }
58
+ /**
59
+ * 备份会话目录
60
+ */
61
+ export async function backupClaudeDir(projectPath) {
62
+ const claudeDir = path.join(projectPath, '.claude');
63
+ const backupDir = path.join(claudeDir, `backup-${Date.now()}`);
64
+ await fs.cp(claudeDir, backupDir, { recursive: true });
65
+ logger.info(`[SessionFileHealth] Backup created: ${backupDir}`);
66
+ return backupDir;
67
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 流式输出缓冲器
3
+ * 按时间窗口批量推送文本和活动事件
4
+ *
5
+ * 延迟策略:
6
+ * - 第1次:立即发送(0ms)
7
+ * - 第2-4次:半延迟(interval / 2)
8
+ * - 第5次起:动态自适应延迟
9
+ * - 计算最近10条消息的平均间隔
10
+ * - 动态延迟 = 平均间隔 * 3
11
+ * - 下限:interval(额定值)
12
+ * - 上限:interval * 2.5
13
+ */
14
+ export class StreamFlusher {
15
+ send;
16
+ interval;
17
+ buffer = '';
18
+ activities = [];
19
+ timer;
20
+ lastFlush = Date.now();
21
+ allText = '';
22
+ sentContent = false;
23
+ fileMarkerPattern;
24
+ flushCount = 0;
25
+ messageTimestamps = [];
26
+ constructor(send, interval = 4000, fileMarkerPattern) {
27
+ this.send = send;
28
+ this.interval = interval;
29
+ this.fileMarkerPattern = fileMarkerPattern;
30
+ }
31
+ addText(text) {
32
+ this.buffer += text;
33
+ this.allText += text;
34
+ this.messageTimestamps.push(Date.now());
35
+ this.scheduleFlush();
36
+ }
37
+ addTextBlock(text) {
38
+ // 用于 assistant 事件的完整文本块,需要换行分隔
39
+ if (this.buffer && !this.buffer.endsWith('\n')) {
40
+ this.buffer += '\n\n';
41
+ this.allText += '\n\n';
42
+ }
43
+ this.buffer += text;
44
+ this.allText += text;
45
+ this.messageTimestamps.push(Date.now());
46
+ this.scheduleFlush();
47
+ }
48
+ addActivity(desc) {
49
+ this.activities.push(desc);
50
+ this.messageTimestamps.push(Date.now());
51
+ this.scheduleFlush();
52
+ }
53
+ /** 当前 buffer 中是否有待发送内容 */
54
+ hasContent() {
55
+ return this.buffer.length > 0 || this.activities.length > 0;
56
+ }
57
+ /** 是否曾经发送过任何内容 */
58
+ hasSentContent() {
59
+ return this.sentContent;
60
+ }
61
+ /** 获取完整累积文本(用于文件标记提取) */
62
+ getFinalText() {
63
+ return this.allText;
64
+ }
65
+ /** 获取当前未发送的剩余文本 */
66
+ getRemainingText() {
67
+ return this.buffer;
68
+ }
69
+ /** 从当前 buffer 中移除匹配的模式 */
70
+ stripFromBuffer(pattern) {
71
+ this.buffer = this.buffer.replace(pattern, '').trim();
72
+ }
73
+ scheduleFlush() {
74
+ if (this.timer)
75
+ return;
76
+ // 计算目标延迟
77
+ let targetDelay;
78
+ if (this.flushCount === 0) {
79
+ // 第1次:立即发送
80
+ targetDelay = 0;
81
+ }
82
+ else if (this.flushCount <= 3) {
83
+ // 第2-4次:半延迟
84
+ targetDelay = Math.ceil(this.interval / 2);
85
+ }
86
+ else if (this.messageTimestamps.length >= 5) {
87
+ // 第5次起:动态自适应
88
+ targetDelay = this.calculateDynamicDelay();
89
+ }
90
+ else {
91
+ // 样本不足,使用额定延迟
92
+ targetDelay = this.interval;
93
+ }
94
+ const elapsed = Date.now() - this.lastFlush;
95
+ const delay = Math.max(0, targetDelay - elapsed);
96
+ this.timer = setTimeout(() => this.flush(), delay);
97
+ }
98
+ /**
99
+ * 计算动态延迟
100
+ * 基于最近10条消息的平均间隔
101
+ */
102
+ calculateDynamicDelay() {
103
+ // 取最近10条(或实际条数)
104
+ const recent = this.messageTimestamps.slice(-10);
105
+ // 计算平均间隔
106
+ const intervals = [];
107
+ for (let i = 1; i < recent.length; i++) {
108
+ intervals.push(recent[i] - recent[i - 1]);
109
+ }
110
+ if (intervals.length === 0) {
111
+ return this.interval;
112
+ }
113
+ const avgInterval = intervals.reduce((a, b) => a + b) / intervals.length;
114
+ // 动态延迟 = 平均间隔 * 3
115
+ let dynamicDelay = avgInterval * 3;
116
+ // 边界限制
117
+ const minDelay = this.interval; // 下限:额定值
118
+ const maxDelay = this.interval * 2.5; // 上限:额定值 * 2.5
119
+ return Math.max(minDelay, Math.min(maxDelay, dynamicDelay));
120
+ }
121
+ async flush(isFinal) {
122
+ if (this.timer) {
123
+ clearTimeout(this.timer);
124
+ this.timer = undefined;
125
+ }
126
+ let output = '';
127
+ if (this.activities.length > 0) {
128
+ output += this.activities.join('\n') + '\n\n';
129
+ this.activities = [];
130
+ }
131
+ if (this.buffer) {
132
+ output += this.buffer;
133
+ this.buffer = '';
134
+ }
135
+ // 移除文件标记(如果配置了)
136
+ if (output && this.fileMarkerPattern) {
137
+ const before = output;
138
+ output = output.replace(this.fileMarkerPattern, '').trim();
139
+ if (before !== output) {
140
+ console.log('[StreamFlusher] Removed file markers, before length:', before.length, 'after:', output.length);
141
+ }
142
+ }
143
+ console.log('[StreamFlusher] flush called, output length:', output.length, 'isEmpty:', !output, 'preview:', output.substring(0, 100));
144
+ if (output) {
145
+ await this.send(output, isFinal);
146
+ this.sentContent = true;
147
+ this.lastFlush = Date.now();
148
+ this.flushCount++;
149
+ }
150
+ }
151
+ }