@myassis/gateway 1.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.
Files changed (65) hide show
  1. package/README.md +194 -0
  2. package/dist/.env +6 -0
  3. package/dist/api/index.js +182 -0
  4. package/dist/config/index.js +41 -0
  5. package/dist/index.js +183 -0
  6. package/dist/middleware/auth.js +53 -0
  7. package/dist/middleware/errorHandler.js +20 -0
  8. package/dist/routes/agent.js +513 -0
  9. package/dist/routes/auth.js +172 -0
  10. package/dist/routes/chat.js +45 -0
  11. package/dist/routes/config.js +21 -0
  12. package/dist/routes/models.js +123 -0
  13. package/dist/routes/service.js +240 -0
  14. package/dist/routes/settings.js +101 -0
  15. package/dist/routes/skillHub.js +126 -0
  16. package/dist/routes/skills.js +159 -0
  17. package/dist/routes/tasks.js +149 -0
  18. package/dist/routes/upload.js +129 -0
  19. package/dist/routes/version.js +66 -0
  20. package/dist/services/HMSPushService.js +24 -0
  21. package/dist/services/LocalTaskService.js +223 -0
  22. package/dist/services/NotificationService.js +242 -0
  23. package/dist/services/ServiceManager.js +348 -0
  24. package/dist/services/TaskSchedulerService.js +195 -0
  25. package/dist/services/TaskService.js +240 -0
  26. package/dist/services/WebSocketService.js +236 -0
  27. package/dist/services/agent/Agent.js +120 -0
  28. package/dist/services/agent/AgentManager.js +265 -0
  29. package/dist/services/agent/AgentStore.js +73 -0
  30. package/dist/services/dataService.js +293 -0
  31. package/dist/services/index.js +15 -0
  32. package/dist/services/llm/LLMClient.js +724 -0
  33. package/dist/services/memory/MemoryManager.js +117 -0
  34. package/dist/services/model/ModelCapabilities.js +141 -0
  35. package/dist/services/model/index.js +4 -0
  36. package/dist/services/models.js +16 -0
  37. package/dist/services/session/MigrationManager.js +176 -0
  38. package/dist/services/session/Session.js +733 -0
  39. package/dist/services/session/SessionManager.js +255 -0
  40. package/dist/services/session/SessionStore.js +186 -0
  41. package/dist/services/session/index.js +3 -0
  42. package/dist/services/skills.js +34 -0
  43. package/dist/services/systemPrompt.js +150 -0
  44. package/dist/services/task/PushTokenStore.js +124 -0
  45. package/dist/services/task/TaskStore.js +143 -0
  46. package/dist/services/tools/calculator.js +27 -0
  47. package/dist/services/tools/edit.js +318 -0
  48. package/dist/services/tools/exec.js +119 -0
  49. package/dist/services/tools/fetch.js +155 -0
  50. package/dist/services/tools/file.js +315 -0
  51. package/dist/services/tools/index.js +48 -0
  52. package/dist/services/tools/keyboard.js +145 -0
  53. package/dist/services/tools/model.js +86 -0
  54. package/dist/services/tools/mouse.js +55 -0
  55. package/dist/services/tools/screenshot.js +19 -0
  56. package/dist/services/tools/search.js +53 -0
  57. package/dist/services/tools/skill.js +108 -0
  58. package/dist/services/tools/task.js +110 -0
  59. package/dist/services/tools/types.js +1 -0
  60. package/dist/services/tools/webFetch.js +34 -0
  61. package/dist/stores/authStore.js +178 -0
  62. package/dist/stores/index.js +6 -0
  63. package/dist/stores/memoryStore.js +191 -0
  64. package/dist/stores/persistStore.js +317 -0
  65. package/package.json +94 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PushTokenStore - 用户设备推送 Token 存储
3
+ * 使用 SQLite 管理用户的多设备推送 Token
4
+ */
5
+ import { getLogger } from '@pocketclaw/shared';
6
+ const logger = getLogger('PushTokenStore');
7
+ import { sessionStore } from '../session/SessionStore';
8
+ export class PushTokenStore {
9
+ db;
10
+ constructor() {
11
+ this.db = sessionStore.getDb();
12
+ this.initTable();
13
+ }
14
+ initTable() {
15
+ this.db.exec(`
16
+ CREATE TABLE IF NOT EXISTS push_tokens (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ user_id TEXT NOT NULL,
19
+ token TEXT NOT NULL,
20
+ provider TEXT NOT NULL DEFAULT 'expo',
21
+ device_id TEXT,
22
+ device_name TEXT,
23
+ created_at INTEGER NOT NULL,
24
+ updated_at INTEGER NOT NULL,
25
+ UNIQUE(user_id, token)
26
+ )
27
+ `);
28
+ // 确保 device_id 和 device_name 列存在(向后兼容)
29
+ this.ensureColumns();
30
+ logger.info('Tables initialized');
31
+ }
32
+ ensureColumns() {
33
+ const tableInfo = this.db.prepare("PRAGMA table_info(push_tokens)").all();
34
+ const columnNames = tableInfo.map((col) => col.name);
35
+ if (!columnNames.includes('device_id')) {
36
+ this.db.exec("ALTER TABLE push_tokens ADD COLUMN device_id TEXT");
37
+ logger.info('Added column: device_id');
38
+ }
39
+ if (!columnNames.includes('device_name')) {
40
+ this.db.exec("ALTER TABLE push_tokens ADD COLUMN device_name TEXT");
41
+ logger.info('Added column: device_name');
42
+ }
43
+ if (!columnNames.includes('provider')) {
44
+ this.db.exec("ALTER TABLE push_tokens ADD COLUMN provider TEXT DEFAULT 'expo'");
45
+ logger.info('Added column: provider');
46
+ }
47
+ }
48
+ /**
49
+ * 添加或更新 Push Token
50
+ */
51
+ upsert(tokenData) {
52
+ const now = Date.now();
53
+ // 先尝试更新
54
+ const existing = this.db.prepare('SELECT id FROM push_tokens WHERE user_id = ? AND token = ?').get(tokenData.userId, tokenData.token);
55
+ if (existing) {
56
+ this.db.prepare(`
57
+ UPDATE push_tokens
58
+ SET provider = ?, device_id = ?, device_name = ?, updated_at = ?
59
+ WHERE user_id = ? AND token = ?
60
+ `).run(tokenData.provider || 'expo', tokenData.deviceId || null, tokenData.deviceName || null, now, tokenData.userId, tokenData.token);
61
+ }
62
+ else {
63
+ this.db.prepare(`
64
+ INSERT INTO push_tokens (user_id, token, provider, device_id, device_name, created_at, updated_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?)
66
+ `).run(tokenData.userId, tokenData.token, tokenData.provider || 'expo', tokenData.deviceId || null, tokenData.deviceName || null, now, now);
67
+ }
68
+ }
69
+ /**
70
+ * 获取用户的所有 Push Token
71
+ */
72
+ getByUserId(userId) {
73
+ const rows = this.db.prepare('SELECT * FROM push_tokens WHERE user_id = ? ORDER BY updated_at DESC').all(userId);
74
+ return rows.map(row => ({
75
+ id: row.id,
76
+ userId: row.user_id,
77
+ token: row.token,
78
+ provider: row.provider,
79
+ deviceId: row.device_id,
80
+ deviceName: row.device_name,
81
+ createdAt: row.created_at,
82
+ updatedAt: row.updated_at,
83
+ }));
84
+ }
85
+ /**
86
+ * 获取用户的所有 Expo Token
87
+ */
88
+ getExpoTokens(userId) {
89
+ const rows = this.db.prepare('SELECT token FROM push_tokens WHERE user_id = ? AND provider = ?').all(userId, 'expo');
90
+ return rows.map(r => r.token);
91
+ }
92
+ /**
93
+ * 获取用户的所有 HMS Token
94
+ */
95
+ getHMSTokens(userId) {
96
+ const rows = this.db.prepare('SELECT token FROM push_tokens WHERE user_id = ? AND provider = ?').all(userId, 'hms');
97
+ return rows.map(r => r.token);
98
+ }
99
+ /**
100
+ * 删除指定的 Token
101
+ */
102
+ delete(token) {
103
+ this.db.prepare('DELETE FROM push_tokens WHERE token = ?').run(token);
104
+ }
105
+ /**
106
+ * 删除用户的指定 Token
107
+ */
108
+ deleteByUserAndToken(userId, token) {
109
+ this.db.prepare('DELETE FROM push_tokens WHERE user_id = ? AND token = ?').run(userId, token);
110
+ }
111
+ /**
112
+ * 删除用户的所有 Token
113
+ */
114
+ deleteByUserId(userId) {
115
+ this.db.prepare('DELETE FROM push_tokens WHERE user_id = ?').run(userId);
116
+ }
117
+ /**
118
+ * 检查 Token 是否存在
119
+ */
120
+ exists(token) {
121
+ const row = this.db.prepare('SELECT id FROM push_tokens WHERE token = ?').get(token);
122
+ return !!row;
123
+ }
124
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * TaskStore - Gateway 任务数据访问层
3
+ * 操作 SQLite 数据库,与 AgentStore/SessionStore 共享同一个数据库实例
4
+ */
5
+ import { getLogger } from '@pocketclaw/shared';
6
+ import { sessionStore } from '../session/SessionStore';
7
+ const logger = getLogger('TaskStore');
8
+ function rowToTaskData(row) {
9
+ return {
10
+ id: row.id,
11
+ userId: row.user_id,
12
+ title: row.title,
13
+ description: row.description || undefined,
14
+ taskType: row.task_type,
15
+ recurrenceRule: row.recurrence_rule || undefined,
16
+ intervalValue: row.interval_value || undefined,
17
+ intervalUnit: row.interval_unit || undefined,
18
+ scheduledAt: row.scheduled_at,
19
+ startTime: row.start_time || undefined,
20
+ endTime: row.end_time || undefined,
21
+ status: row.status,
22
+ platformApply: row.platform_apply || undefined,
23
+ pushToken: row.push_token || undefined,
24
+ createdAt: row.created_at,
25
+ updatedAt: row.updated_at,
26
+ };
27
+ }
28
+ export class TaskStore {
29
+ db;
30
+ constructor(db) {
31
+ // 如果传入了 db 实例(测试用),直接使用
32
+ // 否则从 SessionStore 获取共享的数据库实例
33
+ if (db) {
34
+ this.db = db;
35
+ }
36
+ else {
37
+ this.db = sessionStore.getDb();
38
+ }
39
+ this.initTable();
40
+ }
41
+ initTable() {
42
+ this.db.exec(`
43
+ CREATE TABLE IF NOT EXISTS tasks (
44
+ id TEXT PRIMARY KEY,
45
+ user_id TEXT NOT NULL,
46
+ title TEXT NOT NULL,
47
+ description TEXT,
48
+ task_type TEXT NOT NULL DEFAULT 'one_time',
49
+ recurrence_rule TEXT,
50
+ interval_value INTEGER,
51
+ interval_unit TEXT,
52
+ scheduled_at INTEGER NOT NULL,
53
+ start_time INTEGER,
54
+ end_time INTEGER,
55
+ status TEXT NOT NULL DEFAULT 'pending',
56
+ platform_apply TEXT,
57
+ push_token TEXT,
58
+ created_at INTEGER NOT NULL,
59
+ updated_at INTEGER NOT NULL
60
+ )
61
+ `);
62
+ // 确保必要列存在(向后兼容)
63
+ this.ensureColumns();
64
+ logger.info('Tables initialized');
65
+ }
66
+ ensureColumns() {
67
+ const tableInfo = this.db.prepare("PRAGMA table_info(tasks)").all();
68
+ const columnNames = tableInfo.map((col) => col.name);
69
+ const requiredColumns = [
70
+ { name: 'platform_apply', sql: 'ALTER TABLE tasks ADD COLUMN platform_apply TEXT' },
71
+ { name: 'push_token', sql: 'ALTER TABLE tasks ADD COLUMN push_token TEXT' },
72
+ { name: 'recurrence_rule', sql: 'ALTER TABLE tasks ADD COLUMN recurrence_rule TEXT' },
73
+ { name: 'interval_value', sql: 'ALTER TABLE tasks ADD COLUMN interval_value INTEGER' },
74
+ { name: 'interval_unit', sql: 'ALTER TABLE tasks ADD COLUMN interval_unit TEXT' },
75
+ ];
76
+ for (const col of requiredColumns) {
77
+ if (!columnNames.includes(col.name)) {
78
+ this.db.exec(col.sql);
79
+ logger.info(`Added column: ${col.name}`);
80
+ }
81
+ }
82
+ }
83
+ // ========== Task Operations ==========
84
+ insert(task) {
85
+ this.db.prepare(`
86
+ INSERT INTO tasks (id, user_id, title, description, task_type, recurrence_rule,
87
+ interval_value, interval_unit, scheduled_at, start_time, end_time,
88
+ status, platform_apply, push_token, created_at, updated_at)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
90
+ `).run(task.id, task.userId, task.title, task.description || null, task.taskType, task.recurrenceRule || null, task.intervalValue || null, task.intervalUnit || null, task.scheduledAt, task.startTime || null, task.endTime || null, task.status, task.platformApply || null, task.pushToken || null, task.createdAt, task.updatedAt);
91
+ }
92
+ update(task) {
93
+ this.db.prepare(`
94
+ UPDATE tasks SET
95
+ user_id = ?, title = ?, description = ?, task_type = ?,
96
+ recurrence_rule = ?, interval_value = ?, interval_unit = ?,
97
+ scheduled_at = ?, start_time = ?, end_time = ?,
98
+ status = ?, platform_apply = ?, push_token = ?, updated_at = ?
99
+ WHERE id = ?
100
+ `).run(task.userId, task.title, task.description || null, task.taskType, task.recurrenceRule || null, task.intervalValue || null, task.intervalUnit || null, task.scheduledAt, task.startTime || null, task.endTime || null, task.status, task.platformApply || null, task.pushToken || null, task.updatedAt, task.id);
101
+ }
102
+ delete(id) {
103
+ this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
104
+ }
105
+ findById(id) {
106
+ const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
107
+ return row ? rowToTaskData(row) : null;
108
+ }
109
+ findByUserId(userId) {
110
+ const rows = this.db.prepare('SELECT * FROM tasks WHERE user_id = ? ORDER BY scheduled_at ASC').all(userId);
111
+ return rows.map(rowToTaskData);
112
+ }
113
+ findByStatus(status) {
114
+ const rows = this.db.prepare('SELECT * FROM tasks WHERE status = ? ORDER BY scheduled_at ASC').all(status);
115
+ return rows.map(rowToTaskData);
116
+ }
117
+ findByUserAndStatus(userId, status) {
118
+ const rows = this.db.prepare('SELECT * FROM tasks WHERE user_id = ? AND status = ? ORDER BY scheduled_at ASC').all(userId, status);
119
+ return rows.map(rowToTaskData);
120
+ }
121
+ findPendingAtTime(timestamp) {
122
+ // 查找状态为 pending 且计划时间 <= 指定时间戳的任务
123
+ const rows = this.db.prepare('SELECT * FROM tasks WHERE status = ? AND scheduled_at <= ? ORDER BY scheduled_at ASC').all('pending', timestamp);
124
+ return rows.map(rowToTaskData);
125
+ }
126
+ findByStatusAndBeforeTime(status, timestamp) {
127
+ const rows = this.db.prepare('SELECT * FROM tasks WHERE status = ? AND scheduled_at <= ? ORDER BY scheduled_at ASC').all(status, timestamp);
128
+ return rows.map(rowToTaskData);
129
+ }
130
+ findAll() {
131
+ const rows = this.db.prepare('SELECT * FROM tasks ORDER BY scheduled_at ASC').all();
132
+ return rows.map(rowToTaskData);
133
+ }
134
+ updateStatus(id, status) {
135
+ this.db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?')
136
+ .run(status, Date.now(), id);
137
+ }
138
+ count() {
139
+ const result = this.db.prepare('SELECT COUNT(*) as count FROM tasks').get();
140
+ return result.count;
141
+ }
142
+ }
143
+ export const taskStore = new TaskStore();
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 计算器工具 - 执行数学计算
3
+ */
4
+ export const calculatorTool = {
5
+ name: 'calculator',
6
+ description: '执行数学计算,支持加减乘除和括号',
7
+ parameters: {
8
+ type: 'object',
9
+ properties: {
10
+ expression: {
11
+ type: 'string',
12
+ description: '数学表达式,如: 2 + 3 * 4',
13
+ },
14
+ },
15
+ required: ['expression'],
16
+ },
17
+ handler: async (args) => {
18
+ try {
19
+ const expr = String(args.expression || '').replace(/[^0-9+\-*/().]/g, '');
20
+ const result = Function(`"use strict"; return (${expr})`)();
21
+ return { success: true, output: String(result) };
22
+ }
23
+ catch (error) {
24
+ return { success: false, errorMessage: `计算失败: ${error?.message || error}` };
25
+ }
26
+ },
27
+ };
@@ -0,0 +1,318 @@
1
+ /**
2
+ * 文件编辑工具 - 行范围编辑(Plan A)
3
+ *
4
+ * 设计原则:
5
+ * - LLM 提供行号范围(oldRange),工具负责读取、验证、执行
6
+ * - 每个 edit 只处理一个行范围,多次编辑拆成多个 edit
7
+ * - 验证机制:LLM 可提供 oldContent 供工具比对,不匹配时返回实际内容供修正
8
+ * - 多 edit 时从后往前执行,避免行号偏移
9
+ *
10
+ * 安全机制:
11
+ * - 路径白名单校验,禁止路径穿越
12
+ * - 文件不存在时拒绝操作(不允许静默创建)
13
+ * - 目录不存在时拒绝操作(不允许递归创建目录)
14
+ * - 文件大小限制(默认 10MB)
15
+ * - 编辑操作数量限制(默认 20 个)
16
+ * - 编辑范围重叠检测
17
+ * - 敏感内容脱敏(限制错误返回中的内容长度)
18
+ * - 异步 I/O,不阻塞事件循环
19
+ *
20
+ * LLM 用法:
21
+ * 1. 先用 read 读取文件(拿到行号和内容)
22
+ * 2. 指定 oldRange: { startLine, endLine } + newContent
23
+ * 3. 可选提供 oldContent 让工具验证替换区间的实际内容
24
+ * 4. 验证失败时工具返回实际内容,LLM 据此修正后重试
25
+ */
26
+ import fs from 'fs/promises';
27
+ import path from 'path';
28
+ import os from 'os';
29
+ import { getLogger } from '@pocketclaw/shared';
30
+ const logger = getLogger('edit');
31
+ // ---------- 安全常量 ----------
32
+ /** 允许访问的根目录白名单 */
33
+ const ALLOWED_ROOTS = [
34
+ os.homedir(),
35
+ os.tmpdir(),
36
+ process.env.WORKSPACE_DIR || '',
37
+ process.env.GATEWAY_DIR || '',
38
+ ].filter(Boolean).map(p => path.resolve(p));
39
+ /** 最大文件大小(10MB) */
40
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
41
+ /** 单次最大编辑操作数 */
42
+ const MAX_EDITS = 20;
43
+ /** 错误信息中返回的最大内容长度 */
44
+ const MAX_ERROR_CONTENT_LENGTH = 500;
45
+ // ---------- 安全校验 ----------
46
+ /**
47
+ * 路径安全校验:检测路径穿越并验证路径在白名单内
48
+ */
49
+ function isPathAllowed(filePath) {
50
+ // 标准化路径,解析 .. 等相对路径组件
51
+ const resolved = path.resolve(filePath);
52
+ const normalized = path.normalize(filePath);
53
+ // 检测路径穿越:标准化后仍包含 .. 表示非法穿越
54
+ if (normalized.includes('..')) {
55
+ return { allowed: false, reason: `路径包含穿越组件(..): ${filePath}` };
56
+ }
57
+ // 校验路径是否在允许的根目录下
58
+ const isUnderAllowedRoot = ALLOWED_ROOTS.some(root => resolved.startsWith(root + path.sep) || resolved === root);
59
+ // if (!isUnderAllowedRoot) {
60
+ // return {
61
+ // allowed: false,
62
+ // reason: `路径不在允许范围内: ${resolved}`,
63
+ // };
64
+ // }
65
+ return { allowed: true };
66
+ }
67
+ /**
68
+ * 检测敏感文件路径
69
+ */
70
+ function isSensitivePath(filePath) {
71
+ const lower = filePath.toLowerCase();
72
+ const patterns = ['.env', 'secret', 'password', 'token', 'credential', 'private_key', '.pem', '.key'];
73
+ return patterns.some(p => lower.includes(p));
74
+ }
75
+ /**
76
+ * 内容脱敏:敏感文件隐藏内容,大文件截断
77
+ */
78
+ function sanitizeContent(content, filePath) {
79
+ if (isSensitivePath(filePath)) {
80
+ return `[敏感文件内容已隐藏,共 ${content.length} 字符]`;
81
+ }
82
+ if (content.length > MAX_ERROR_CONTENT_LENGTH) {
83
+ return content.slice(0, MAX_ERROR_CONTENT_LENGTH) +
84
+ `\n... [已截断,共 ${content.length} 字符]`;
85
+ }
86
+ return content;
87
+ }
88
+ // ---------- 核心逻辑 ----------
89
+ /**
90
+ * 读取指定行范围(1-indexed)的原始内容
91
+ */
92
+ function getRangeContent(lines, startLine, endLine) {
93
+ return lines.slice(startLine - 1, endLine).join('\n');
94
+ }
95
+ /**
96
+ * 执行单个行范围替换
97
+ */
98
+ function applyRangeEdit(lines, startLine, endLine, newContent) {
99
+ const before = lines.slice(0, startLine - 1);
100
+ const after = lines.slice(endLine);
101
+ const newLines = newContent ? newContent.split('\n') : [];
102
+ return [...before, ...newLines, ...after];
103
+ }
104
+ /**
105
+ * 检测编辑范围是否重叠
106
+ */
107
+ function hasOverlappingRanges(edits) {
108
+ for (let i = 0; i < edits.length; i++) {
109
+ for (let j = i + 1; j < edits.length; j++) {
110
+ const a = edits[i].oldRange;
111
+ const b = edits[j].oldRange;
112
+ if (a.startLine <= b.endLine && b.startLine <= a.endLine) {
113
+ return {
114
+ overlap: true,
115
+ message: `编辑范围重叠:edits[${i}](第 ${a.startLine}-${a.endLine} 行)与 edits[${j}](第 ${b.startLine}-${b.endLine} 行)重叠`,
116
+ };
117
+ }
118
+ }
119
+ }
120
+ return { overlap: false };
121
+ }
122
+ // ---------- 工具定义 ----------
123
+ const editTool = {
124
+ name: 'edit',
125
+ description: '文件编辑工具(行范围模式)。' +
126
+ 'LLM 先用 read 读取文件获得行号,然后指定 oldRange(起始行、结束行)和 newContent(替换后的内容)。' +
127
+ '可选提供 oldContent 让工具验证实际内容是否与预期一致。' +
128
+ '支持一次调用多个 edit(从后往前执行避免行号偏移)。',
129
+ parameters: {
130
+ type: 'object',
131
+ properties: {
132
+ path: { type: 'string', description: '文件路径' },
133
+ edits: {
134
+ type: 'array',
135
+ description: '编辑操作列表',
136
+ items: {
137
+ type: 'object',
138
+ description: '单个行范围编辑操作',
139
+ properties: {
140
+ oldRange: {
141
+ type: 'object',
142
+ description: '要替换的行范围(1-indexed,包含 startLine 和 endLine)',
143
+ properties: {
144
+ startLine: { type: 'number', description: '起始行号(1-indexed)' },
145
+ endLine: { type: 'number', description: '结束行号(1-indexed,必须 >= startLine)' },
146
+ },
147
+ required: ['startLine', 'endLine'],
148
+ },
149
+ newContent: {
150
+ type: 'string',
151
+ description: '替换后的内容(留空或不填表示删除该行范围)',
152
+ },
153
+ oldContent: {
154
+ type: 'string',
155
+ description: '【可选】LLM 认为该范围内原有的内容。工具会比对,不一致时返回实际内容供修正',
156
+ },
157
+ },
158
+ required: ['oldRange'],
159
+ },
160
+ }
161
+ },
162
+ required: ['path', 'edits'],
163
+ },
164
+ handler: async (args) => {
165
+ try {
166
+ const { path: filePath, edits } = args;
167
+ // ---- 参数校验 ----
168
+ if (!filePath)
169
+ return { success: false, errorMessage: '缺少文件路径(path)' };
170
+ if (!Array.isArray(edits) || edits.length === 0)
171
+ return { success: false, errorMessage: 'edits 必须为非空数组' };
172
+ for (let i = 0; i < edits.length; i++) {
173
+ const { oldRange } = edits[i];
174
+ if (!oldRange || typeof oldRange.startLine !== 'number' || typeof oldRange.endLine !== 'number')
175
+ return { success: false, errorMessage: `edits[${i}]: 必须提供有效的 oldRange(startLine 和 endLine)` };
176
+ if (oldRange.startLine < 1)
177
+ return { success: false, errorMessage: `edits[${i}]: startLine 必须 >= 1` };
178
+ if (oldRange.endLine < oldRange.startLine)
179
+ return { success: false, errorMessage: `edits[${i}]: endLine (${oldRange.endLine}) 必须 >= startLine (${oldRange.startLine})` };
180
+ }
181
+ // ---- 路径安全校验 ----
182
+ const pathCheck = isPathAllowed(filePath);
183
+ if (!pathCheck.allowed) {
184
+ return { success: false, errorMessage: pathCheck.reason };
185
+ }
186
+ // ---- 文件存在性 & 大小校验 ----
187
+ let original;
188
+ try {
189
+ const stats = await fs.stat(filePath);
190
+ if (stats.size > MAX_FILE_SIZE) {
191
+ return {
192
+ success: false,
193
+ errorMessage: `文件过大(${stats.size} 字节),超出最大允许 ${MAX_FILE_SIZE} 字节`,
194
+ };
195
+ }
196
+ original = await fs.readFile(filePath, 'utf-8');
197
+ }
198
+ catch (err) {
199
+ if (err.code === 'ENOENT') {
200
+ return { success: false, errorMessage: `文件不存在,不允许创建新文件: ${filePath}` };
201
+ }
202
+ return { success: false, errorMessage: `读取文件失败: ${err.message}` };
203
+ }
204
+ // ---- 编辑操作数量校验 ----
205
+ if (edits.length > MAX_EDITS) {
206
+ return {
207
+ success: false,
208
+ errorMessage: `单次最多 ${MAX_EDITS} 个编辑操作,当前 ${edits.length} 个`,
209
+ };
210
+ }
211
+ const origLines = original.split('\n').filter((_, i, arr) => !(i === arr.length - 1 && _ === ''));
212
+ const totalLines = origLines.length;
213
+ // ---- 验证每个 edit 的行范围 ----
214
+ for (let i = 0; i < edits.length; i++) {
215
+ const { oldRange } = edits[i];
216
+ if (oldRange.startLine > totalLines)
217
+ return {
218
+ success: false,
219
+ errorMessage: `edits[${i}]: startLine=${oldRange.startLine} 超出文件总行数(${totalLines})。`,
220
+ };
221
+ if (oldRange.endLine > totalLines)
222
+ return {
223
+ success: false,
224
+ errorMessage: `edits[${i}]: endLine=${oldRange.endLine} 超出文件总行数(${totalLines})。`,
225
+ };
226
+ }
227
+ // ---- 编辑范围重叠检测 ----
228
+ const overlapCheck = hasOverlappingRanges(edits);
229
+ if (overlapCheck.overlap) {
230
+ return { success: false, errorMessage: overlapCheck.message };
231
+ }
232
+ // ---- 验证 oldContent(如果有) ----
233
+ for (let i = 0; i < edits.length; i++) {
234
+ const { oldRange, oldContent } = edits[i];
235
+ if (!oldContent)
236
+ continue;
237
+ const actualContent = getRangeContent(origLines, oldRange.startLine, oldRange.endLine);
238
+ if (actualContent !== oldContent) {
239
+ const safeContent = sanitizeContent(actualContent, filePath);
240
+ return {
241
+ success: false,
242
+ errorMessage: `edits[${i}]: 提供的 oldContent 与文件第 ${oldRange.startLine}-${oldRange.endLine} 行实际内容不一致。` +
243
+ `工具发现的内容:\n\`\`\`\n${safeContent}\n\`\`\``,
244
+ };
245
+ }
246
+ }
247
+ // ---- 从后往前执行替换(避免行号偏移) ----
248
+ let currentLines = [...origLines];
249
+ const sortedEdits = [...edits].sort((a, b) => b.oldRange.endLine - a.oldRange.endLine);
250
+ // 用 Map 记录 edit -> 原始索引(修复 indexOf 重复问题)
251
+ const editIndexMap = new Map();
252
+ edits.forEach((edit, idx) => editIndexMap.set(edit, idx));
253
+ const results = [];
254
+ for (const edit of sortedEdits) {
255
+ const { oldRange, newContent = '' } = edit;
256
+ const { startLine, endLine } = oldRange;
257
+ currentLines = applyRangeEdit(currentLines, startLine, endLine, newContent);
258
+ results.push({
259
+ editIndex: editIndexMap.get(edit) ?? -1,
260
+ startLine,
261
+ endLine,
262
+ newLines: newContent ? newContent.split('\n').length : 0,
263
+ });
264
+ }
265
+ const modified = currentLines.join('\n');
266
+ // ---- 内容未变检测 ----
267
+ if (modified === original) {
268
+ return {
269
+ success: true,
270
+ output: JSON.stringify({
271
+ path: filePath,
272
+ unchanged: true,
273
+ message: '文件内容未变化,未执行写入',
274
+ }),
275
+ };
276
+ }
277
+ // ---- 写入文件(仅当目录已存在时) ----
278
+ const dir = path.dirname(filePath);
279
+ try {
280
+ const dirStat = await fs.stat(dir);
281
+ if (!dirStat.isDirectory()) {
282
+ return { success: false, errorMessage: `目标路径不是目录: ${dir}` };
283
+ }
284
+ }
285
+ catch (_err) {
286
+ return { success: false, errorMessage: `目标目录不存在,拒绝创建新目录: ${dir}` };
287
+ }
288
+ // ---- 写入修改后的内容 ----
289
+ await fs.writeFile(filePath, modified, 'utf-8');
290
+ logger.info(`edit 完成: ${filePath}`, {
291
+ editCount: edits.length,
292
+ originalSize: original.length,
293
+ modifiedSize: modified.length,
294
+ originalLines: totalLines,
295
+ modifiedLines: currentLines.length,
296
+ });
297
+ return {
298
+ success: true,
299
+ output: JSON.stringify({
300
+ path: filePath,
301
+ editCount: edits.length,
302
+ stats: {
303
+ originalLines: totalLines,
304
+ modifiedLines: currentLines.length,
305
+ linesChanged: currentLines.length - totalLines,
306
+ originalSize: original.length,
307
+ modifiedSize: modified.length,
308
+ },
309
+ }),
310
+ };
311
+ }
312
+ catch (err) {
313
+ logger.error('edit 工具执行失败', { error: err.message });
314
+ return { success: false, errorMessage: `edit 失败: ${err.message}` };
315
+ }
316
+ },
317
+ };
318
+ export { editTool };