minimal-agent 0.2.0 → 0.3.1
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 +54 -72
- package/package.json +18 -13
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/commands/workflow.md +13 -3
- package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +216 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/{tool.ts → tool.js} +19 -25
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +197 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/dist/main.js +0 -5315
- package/plugins/ralph-wiggum/plugin.ts +0 -275
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
- package/plugins/ralph-wiggum/src/goalState.ts +0 -310
- package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
- package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
- package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
- package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
- package/plugins/workflow-runner/src/expressions.ts +0 -371
- package/plugins/workflow-runner/src/index.ts +0 -194
- package/plugins/workflow-runner/src/loader.ts +0 -193
- package/plugins/workflow-runner/src/runner.ts +0 -313
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
- package/plugins/workflow-runner/src/types.ts +0 -183
- package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
- package/plugins/workflow-runner/test/e2e.test.ts +0 -268
- package/plugins/workflow-runner/test/expressions.test.ts +0 -140
- package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
- package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
- package/plugins/workflow-runner/test/graceful.test.ts +0 -139
- package/plugins/workflow-runner/test/loader.test.ts +0 -216
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
- package/plugins/workflow-runner/test/runner.test.ts +0 -511
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/shared/fileState.ts —— Pre-read Guard 状态表
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 对应上游 D:\code\kakadeai\utils\fileStateCache.ts:4-14
|
|
6
|
+
* + D:\code\kakadeai\tools\FileWriteTool\FileWriteTool.ts:198-219 的"先读后写"约束。
|
|
7
|
+
*
|
|
8
|
+
* 纯 module-level Map,记录每个文件最近一次被 Read 时的 (mtime, size)。
|
|
9
|
+
* Write/Edit 在落盘前调用 assertFresh:
|
|
10
|
+
* 1. 没有记录 + 文件已存在 → 报错"请先 Read"
|
|
11
|
+
* 2. 没有记录 + 文件不存在 → 放行(新建场景)
|
|
12
|
+
* 3. 当前 mtime > 记录的 mtime → 报错"文件被外部修改"
|
|
13
|
+
*
|
|
14
|
+
* /new 调用 clearContext 时连带调 clearFileState() 防残留。
|
|
15
|
+
* ============================================================
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, statSync } from 'node:fs';
|
|
18
|
+
const fileState = new Map();
|
|
19
|
+
export function recordRead(absPath) {
|
|
20
|
+
try {
|
|
21
|
+
const st = statSync(absPath);
|
|
22
|
+
fileState.set(absPath, { timestamp: st.mtimeMs, size: st.size });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// 文件不存在或无权访问 —— 不记录,让 Read 自己处理错误
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function assertFresh(absPath) {
|
|
29
|
+
const entry = fileState.get(absPath);
|
|
30
|
+
if (!entry) {
|
|
31
|
+
if (existsSync(absPath)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: `文件 ${absPath} 已存在但未在本会话 Read 过。请先用 Read 工具读取,确认内容后再修改。`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// 文件不存在 → 新建场景,放行
|
|
38
|
+
return { ok: true };
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const st = statSync(absPath);
|
|
42
|
+
if (st.mtimeMs > entry.timestamp) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: `${absPath} 在 Read 后被外部修改(mtime 漂移)。请重新用 Read 工具读取最新内容。`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// 记录存在但文件没了 —— 不阻拦,允许重新创建
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
export function clearFileState() {
|
|
56
|
+
fileState.clear();
|
|
57
|
+
}
|
|
58
|
+
// 测试辅助:只在测试里用,业务代码不要碰
|
|
59
|
+
export function _getFileStateSize() {
|
|
60
|
+
return fileState.size;
|
|
61
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/shared/fileUtils.ts —— Read/Edit/Write 共享工具函数
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 从 kakadeai 主仓库提炼的核心能力,剥离 UI/LSP/权限等高级层:
|
|
6
|
+
* 1. 设备文件路径拦截(防止 /dev/zero 等阻塞)
|
|
7
|
+
* 2. 路径安全校验 + 规范化(null byte / UNC / ~ 展开)
|
|
8
|
+
* 3. 行尾符检测与保持(CRLF vs LF)
|
|
9
|
+
* 4. 引号风格保留(弯引号 ↔ 直引号归一化)
|
|
10
|
+
* ============================================================
|
|
11
|
+
*/
|
|
12
|
+
import { open, readFile } from 'node:fs/promises';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { extname, resolve } from 'node:path';
|
|
15
|
+
import { getWorkingDir } from '../../bootstrap/workingDir.js';
|
|
16
|
+
// -------------------- 1. 设备文件路径拦截 --------------------
|
|
17
|
+
const BLOCKED_DEVICE_PATHS = new Set([
|
|
18
|
+
'/dev/zero',
|
|
19
|
+
'/dev/random',
|
|
20
|
+
'/dev/urandom',
|
|
21
|
+
'/dev/full',
|
|
22
|
+
'/dev/stdin',
|
|
23
|
+
'/dev/tty',
|
|
24
|
+
'/dev/console',
|
|
25
|
+
'/dev/stdout',
|
|
26
|
+
'/dev/stderr',
|
|
27
|
+
'/dev/fd/0',
|
|
28
|
+
'/dev/fd/1',
|
|
29
|
+
'/dev/fd/2',
|
|
30
|
+
]);
|
|
31
|
+
const WINDOWS_BLOCKED_NAMES = new Set(['NUL', 'CON', 'PRN', 'AUX', 'COM1', 'COM2', 'LPT1']);
|
|
32
|
+
export function isBlockedDevicePath(filePath) {
|
|
33
|
+
// 用 forward-slash 归一化:path.normalize 在 Windows 上会把 / 转成 \,
|
|
34
|
+
// 黑名单是 POSIX 字面量永远对不上,所以直接做反斜杠 → 正斜杠的归一化
|
|
35
|
+
const slashed = filePath.replaceAll('\\', '/');
|
|
36
|
+
if (BLOCKED_DEVICE_PATHS.has(slashed))
|
|
37
|
+
return true;
|
|
38
|
+
if (slashed.startsWith('/proc/') &&
|
|
39
|
+
(slashed.endsWith('/fd/0') || slashed.endsWith('/fd/1') || slashed.endsWith('/fd/2'))) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
const baseName = slashed.split('/').pop() ?? '';
|
|
43
|
+
if (WINDOWS_BLOCKED_NAMES.has(baseName.toUpperCase())) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
export function validateAndResolvePath(rawPath, workingDir) {
|
|
49
|
+
if (rawPath.includes('\0')) {
|
|
50
|
+
return { ok: false, error: '路径包含非法字符(null byte)' };
|
|
51
|
+
}
|
|
52
|
+
// 在 resolve 之前先按原始路径检查设备文件:
|
|
53
|
+
// Windows 上 resolve('D:\\workdir', '/dev/zero') → 'D:\\dev\\zero',已丢失 POSIX 语义
|
|
54
|
+
if (isBlockedDevicePath(rawPath)) {
|
|
55
|
+
return { ok: false, error: `不允许读取设备文件:${rawPath}。该路径可能产生无限输出或阻塞进程。` };
|
|
56
|
+
}
|
|
57
|
+
const expanded = expandPath(rawPath);
|
|
58
|
+
const resolved = resolve(workingDir, expanded);
|
|
59
|
+
// resolve 后再查一次(兜底 Windows 设备名如 NUL/CON 经 resolve 后仍能 baseName 命中)
|
|
60
|
+
if (isBlockedDevicePath(resolved)) {
|
|
61
|
+
return { ok: false, error: `不允许读取设备文件:${resolved}。该路径可能产生无限输出或阻塞进程。` };
|
|
62
|
+
}
|
|
63
|
+
if (process.platform === 'win32' && /^\\\\/.test(resolved)) {
|
|
64
|
+
return { ok: false, error: '不支持 UNC 路径(\\\\server\\share 格式),请使用本地路径' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, resolvedPath: resolved };
|
|
67
|
+
}
|
|
68
|
+
function expandPath(p) {
|
|
69
|
+
if (p.startsWith('~/') || p === '~') {
|
|
70
|
+
return resolve(homedir(), p.slice(2));
|
|
71
|
+
}
|
|
72
|
+
return p;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 工具入口便捷封装:自动以 getWorkingDir() 为锚点做路径规范化 + 安全校验。
|
|
76
|
+
* 等价于 `validateAndResolvePath(rawPath, getWorkingDir())`。
|
|
77
|
+
* 让 Read / Write / Edit / MultiEdit 不必各自 import getWorkingDir。
|
|
78
|
+
*/
|
|
79
|
+
export function resolveToolPath(rawPath) {
|
|
80
|
+
return validateAndResolvePath(rawPath, getWorkingDir());
|
|
81
|
+
}
|
|
82
|
+
export function detectLineEndingsForString(content) {
|
|
83
|
+
let crlfCount = 0;
|
|
84
|
+
let lfCount = 0;
|
|
85
|
+
for (let i = 0; i < content.length; i++) {
|
|
86
|
+
if (content[i] === '\n') {
|
|
87
|
+
if (i > 0 && content[i - 1] === '\r') {
|
|
88
|
+
crlfCount++;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
lfCount++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return crlfCount > lfCount ? 'CRLF' : 'LF';
|
|
96
|
+
}
|
|
97
|
+
export async function detectFileLineEndings(filePath) {
|
|
98
|
+
try {
|
|
99
|
+
const handle = await readFile(filePath, { encoding: 'utf8' });
|
|
100
|
+
const head = handle.slice(0, 4096);
|
|
101
|
+
return detectLineEndingsForString(head);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return 'LF';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function applyLineEnding(content, ending) {
|
|
108
|
+
if (ending === 'CRLF') {
|
|
109
|
+
return content.replaceAll('\r\n', '\n').split('\n').join('\r\n');
|
|
110
|
+
}
|
|
111
|
+
return content;
|
|
112
|
+
}
|
|
113
|
+
// -------------------- 3.5 二进制扩展名拦截 --------------------
|
|
114
|
+
// 上游 D:\code\kakadeai\constants\files.ts:5-112 BINARY_EXTENSIONS Set 的裁剪版:
|
|
115
|
+
// 故意剔除 .log / .csv / .tsv / .dat / .data / .lock —— 这些经常被人当文本读。
|
|
116
|
+
// 保留纯二进制:图片 / 文档 / 可执行 / 编译产物 / 压缩包 / 多媒体 / 字体 / 数据库 / wasm。
|
|
117
|
+
export const BINARY_EXTENSIONS = new Set([
|
|
118
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.tiff', '.tif',
|
|
119
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
120
|
+
'.exe', '.dll', '.so', '.dylib', '.o', '.a',
|
|
121
|
+
'.pyc', '.pyo', '.class', '.jar',
|
|
122
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar', '.iso',
|
|
123
|
+
'.mp3', '.mp4', '.mov', '.avi', '.mkv', '.wav', '.flac', '.ogg',
|
|
124
|
+
'.ttf', '.otf', '.woff', '.woff2',
|
|
125
|
+
'.sqlite', '.sqlite3', '.db',
|
|
126
|
+
'.psd', '.ai', '.bin', '.wasm',
|
|
127
|
+
]);
|
|
128
|
+
export function hasBinaryExtension(filePath) {
|
|
129
|
+
const ext = extname(filePath).toLowerCase();
|
|
130
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 读文件前 3 字节,返回 BOM 类型。
|
|
134
|
+
* - EF BB BF → utf8-bom
|
|
135
|
+
* - FF FE → utf16le
|
|
136
|
+
* - 其它 / 空 / 出错 → utf8(默认)
|
|
137
|
+
*/
|
|
138
|
+
export async function detectFileBomEncoding(filePath) {
|
|
139
|
+
let fh = null;
|
|
140
|
+
try {
|
|
141
|
+
fh = await open(filePath, 'r');
|
|
142
|
+
const buf = Buffer.alloc(3);
|
|
143
|
+
const { bytesRead } = await fh.read(buf, 0, 3, 0);
|
|
144
|
+
if (bytesRead >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
145
|
+
return 'utf8-bom';
|
|
146
|
+
}
|
|
147
|
+
if (bytesRead >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
148
|
+
return 'utf16le';
|
|
149
|
+
}
|
|
150
|
+
return 'utf8';
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return 'utf8';
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
if (fh) {
|
|
157
|
+
try {
|
|
158
|
+
await fh.close();
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// -------------------- 4. 引号风格保留 --------------------
|
|
165
|
+
export const LEFT_SINGLE_CURLY_QUOTE = '\u2018';
|
|
166
|
+
export const RIGHT_SINGLE_CURLY_QUOTE = '\u2019';
|
|
167
|
+
export const LEFT_DOUBLE_CURLY_QUOTE = '\u201C';
|
|
168
|
+
export const RIGHT_DOUBLE_CURLY_QUOTE = '\u201D';
|
|
169
|
+
export function normalizeQuotes(str) {
|
|
170
|
+
return str
|
|
171
|
+
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
|
172
|
+
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
|
173
|
+
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
|
174
|
+
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
|
|
175
|
+
}
|
|
176
|
+
export function findActualString(fileContent, searchString) {
|
|
177
|
+
if (fileContent.includes(searchString)) {
|
|
178
|
+
return searchString;
|
|
179
|
+
}
|
|
180
|
+
const normalizedSearch = normalizeQuotes(searchString);
|
|
181
|
+
const normalizedFile = normalizeQuotes(fileContent);
|
|
182
|
+
const searchIndex = normalizedFile.indexOf(normalizedSearch);
|
|
183
|
+
if (searchIndex !== -1) {
|
|
184
|
+
return fileContent.substring(searchIndex, searchIndex + searchString.length);
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
export function preserveQuoteStyle(oldString, actualOldString, newString) {
|
|
189
|
+
if (oldString === actualOldString) {
|
|
190
|
+
return newString;
|
|
191
|
+
}
|
|
192
|
+
const hasDoubleQuotes = actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
|
|
193
|
+
actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE);
|
|
194
|
+
const hasSingleQuotes = actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
|
|
195
|
+
actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE);
|
|
196
|
+
if (!hasDoubleQuotes && !hasSingleQuotes) {
|
|
197
|
+
return newString;
|
|
198
|
+
}
|
|
199
|
+
let result = newString;
|
|
200
|
+
if (hasDoubleQuotes) {
|
|
201
|
+
result = applyCurlyDoubleQuotes(result);
|
|
202
|
+
}
|
|
203
|
+
if (hasSingleQuotes) {
|
|
204
|
+
result = applyCurlySingleQuotes(result);
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
function isOpeningContext(chars, index) {
|
|
209
|
+
if (index === 0)
|
|
210
|
+
return true;
|
|
211
|
+
const prev = chars[index - 1];
|
|
212
|
+
return (prev === ' ' ||
|
|
213
|
+
prev === '\t' ||
|
|
214
|
+
prev === '\n' ||
|
|
215
|
+
prev === '\r' ||
|
|
216
|
+
prev === '(' ||
|
|
217
|
+
prev === '[' ||
|
|
218
|
+
prev === '{' ||
|
|
219
|
+
prev === '\u2014' ||
|
|
220
|
+
prev === '\u2013');
|
|
221
|
+
}
|
|
222
|
+
function applyCurlyDoubleQuotes(str) {
|
|
223
|
+
const chars = [...str];
|
|
224
|
+
const result = [];
|
|
225
|
+
for (let i = 0; i < chars.length; i++) {
|
|
226
|
+
if (chars[i] === '"') {
|
|
227
|
+
result.push(isOpeningContext(chars, i)
|
|
228
|
+
? LEFT_DOUBLE_CURLY_QUOTE
|
|
229
|
+
: RIGHT_DOUBLE_CURLY_QUOTE);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
result.push(chars[i]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return result.join('');
|
|
236
|
+
}
|
|
237
|
+
function applyCurlySingleQuotes(str) {
|
|
238
|
+
const chars = [...str];
|
|
239
|
+
const result = [];
|
|
240
|
+
for (let i = 0; i < chars.length; i++) {
|
|
241
|
+
if (chars[i] === "'") {
|
|
242
|
+
const prev = i > 0 ? chars[i - 1] : undefined;
|
|
243
|
+
const next = i < chars.length - 1 ? chars[i + 1] : undefined;
|
|
244
|
+
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev);
|
|
245
|
+
const nextIsLetter = next !== undefined && /\p{L}/u.test(next);
|
|
246
|
+
if (prevIsLetter && nextIsLetter) {
|
|
247
|
+
result.push(RIGHT_SINGLE_CURLY_QUOTE);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
result.push(isOpeningContext(chars, i)
|
|
251
|
+
? LEFT_SINGLE_CURLY_QUOTE
|
|
252
|
+
: RIGHT_SINGLE_CURLY_QUOTE);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
result.push(chars[i]);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return result.join('');
|
|
260
|
+
}
|
|
261
|
+
// -------------------- 5. 字符串搜索/替换共享工具 --------------------
|
|
262
|
+
/** 统计 needle 在 haystack 中出现多少次(不重叠)。 */
|
|
263
|
+
export function countOccurrences(haystack, needle) {
|
|
264
|
+
if (needle.length === 0)
|
|
265
|
+
return 0;
|
|
266
|
+
let count = 0;
|
|
267
|
+
let pos = 0;
|
|
268
|
+
while ((pos = haystack.indexOf(needle, pos)) !== -1) {
|
|
269
|
+
count++;
|
|
270
|
+
pos += needle.length;
|
|
271
|
+
}
|
|
272
|
+
return count;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* 全部替换。
|
|
276
|
+
* 不用 String.prototype.replaceAll(string, string) 与 String.prototype.replace 是
|
|
277
|
+
* 为了规避 replacement 字符串中 `$&` / `$1` 等被当作回填模式解释的问题。
|
|
278
|
+
*/
|
|
279
|
+
export function splitReplaceAll(haystack, needle, replacement) {
|
|
280
|
+
return haystack.split(needle).join(replacement);
|
|
281
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/shared/schemas.ts —— 工具 Zod 输入 schema 公共片段
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 抽出 Read / Write / Edit / MultiEdit 共享的 file_path 字段,
|
|
6
|
+
* 统一 min length 校验与 describe 文案。
|
|
7
|
+
* 动作动词通过参数注入(读取 / 写入 / 编辑)。
|
|
8
|
+
* ============================================================
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
export function filePathField(action) {
|
|
12
|
+
return z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1, '必须提供 file_path')
|
|
15
|
+
.describe(`要${action}的文件路径(绝对路径优先,相对路径基于 working dir 解析)`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/types.ts —— 工具相关的辅助类型与默认值
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* Tool 接口本身在 src/types.ts 里定义(对外类型)。
|
|
6
|
+
* 这里只放工具子系统内部用的辅助类型 / 常量。
|
|
7
|
+
* ============================================================
|
|
8
|
+
*/
|
|
9
|
+
/** 默认输出截断阈值(30K 字符 ≈ 7.5K token) */
|
|
10
|
+
export const DEFAULT_MAX_RESULT_SIZE_CHARS = 30_000;
|
|
11
|
+
/** Read 工具默认行数上限(与 kakadeai 主仓库一致) */
|
|
12
|
+
export const MAX_LINES_TO_READ = 2000;
|
|
13
|
+
/**
|
|
14
|
+
* Read 工具默认字节上限(防止超大文件灌进上下文)。
|
|
15
|
+
* 基于 200K 上下文窗口设计:
|
|
16
|
+
* - system prompt + 工具定义 ≈ 30KB
|
|
17
|
+
* - 对话历史 ≈ 100KB
|
|
18
|
+
* - 单次读取安全上限 ≈ 256KB(~64K tokens)
|
|
19
|
+
* 超过此限制请用 offset/limit 分段读。
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_FILE_SIZE_BYTES = 256 * 1024; // 256 KB
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/browser.ts —— Playwright Browser 单例
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 整个 session 复用同一个 headless Chromium page。
|
|
6
|
+
* 动态 import playwright-core,按需加载,避免未安装时拖累主程序。
|
|
7
|
+
* ============================================================
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
let browserInstance = null;
|
|
12
|
+
let pageInstance = null;
|
|
13
|
+
/**
|
|
14
|
+
* 获取或创建 browser page 单例。
|
|
15
|
+
* 每次导航后手动更新 pageInstance.url()。
|
|
16
|
+
*/
|
|
17
|
+
export async function getBrowserPage() {
|
|
18
|
+
if (!browserInstance) {
|
|
19
|
+
// 动态 import,按需加载
|
|
20
|
+
const { chromium } = await import('playwright-core');
|
|
21
|
+
try {
|
|
22
|
+
browserInstance = await chromium.launch({
|
|
23
|
+
headless: true,
|
|
24
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
29
|
+
throw new Error(`Failed to launch browser: ${message}\n` +
|
|
30
|
+
`请先安装 playwright 及浏览器:\n` +
|
|
31
|
+
` npm install playwright-core\n` +
|
|
32
|
+
` npx playwright install chromium`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!pageInstance) {
|
|
36
|
+
pageInstance = await browserInstance.newPage();
|
|
37
|
+
await pageInstance.setViewportSize({ width: 1280, height: 720 });
|
|
38
|
+
}
|
|
39
|
+
return pageInstance;
|
|
40
|
+
}
|
|
41
|
+
/** 关闭浏览器(session 结束时调用) */
|
|
42
|
+
export async function closeBrowser() {
|
|
43
|
+
if (pageInstance) {
|
|
44
|
+
await pageInstance.close();
|
|
45
|
+
pageInstance = null;
|
|
46
|
+
}
|
|
47
|
+
if (browserInstance) {
|
|
48
|
+
await browserInstance.close();
|
|
49
|
+
browserInstance = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** 生成截图路径 */
|
|
53
|
+
export function screenshotPath(prefix = 'browser') {
|
|
54
|
+
return path.join(os.tmpdir(), `${prefix}-${Date.now()}.png`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/webbrowser.ts —— WebBrowser 工具
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 用 Playwright 控制 headless 浏览器:导航、截图、内容提取、表单交互。
|
|
6
|
+
*
|
|
7
|
+
* 依赖:playwright-core(需要额外安装浏览器:npx playwright install chromium)
|
|
8
|
+
*
|
|
9
|
+
* 输入:
|
|
10
|
+
* action 必填,'navigate'|'screenshot'|'getContent'|'click'|'fill'|'submit'
|
|
11
|
+
* url 可选,navigate 时必需
|
|
12
|
+
* selector 可选,click/fill/submit 时必需
|
|
13
|
+
* value 可选,fill 时必需
|
|
14
|
+
* timeout 可选,默认 30000ms
|
|
15
|
+
*
|
|
16
|
+
* 输出:
|
|
17
|
+
* { ok: true, content: string } | { ok: false, error: string }
|
|
18
|
+
* ============================================================
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
22
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
23
|
+
import { getBrowserPage, screenshotPath } from './browser.js';
|
|
24
|
+
// ---------------- 1. Zod 输入 schema ----------------
|
|
25
|
+
const inputSchema = z.object({
|
|
26
|
+
action: z
|
|
27
|
+
.enum(['navigate', 'screenshot', 'getContent', 'click', 'fill', 'submit'])
|
|
28
|
+
.describe('Browser action to perform'),
|
|
29
|
+
url: z
|
|
30
|
+
.string()
|
|
31
|
+
.url()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('URL to navigate to (required for navigate action)'),
|
|
34
|
+
selector: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('CSS selector for click/fill/submit actions'),
|
|
38
|
+
value: z.string().optional().describe('Value to fill in input fields'),
|
|
39
|
+
timeout: z
|
|
40
|
+
.number()
|
|
41
|
+
.int()
|
|
42
|
+
.positive()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe('Timeout in milliseconds (default: 30000)'),
|
|
45
|
+
});
|
|
46
|
+
// ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
|
|
47
|
+
const parameters = toToolParameters(inputSchema);
|
|
48
|
+
// ---------------- 3. Description ----------------
|
|
49
|
+
const description = `Control a headless web browser. Navigate to URLs, take screenshots, and interact with web pages.
|
|
50
|
+
|
|
51
|
+
When to use WebBrowser vs WebSearch:
|
|
52
|
+
- WebSearch: When you need to find information or discover URLs through search
|
|
53
|
+
- WebBrowser: When you need to interact with a specific web page (navigate, click, fill forms, get content, take screenshots)
|
|
54
|
+
|
|
55
|
+
Input parameters:
|
|
56
|
+
- action (required): The browser action. One of:
|
|
57
|
+
- navigate: Go to a URL (requires url parameter)
|
|
58
|
+
- screenshot: Take a screenshot of the current page
|
|
59
|
+
- getContent: Extract text content from the current page
|
|
60
|
+
- click: Click an element (requires selector parameter)
|
|
61
|
+
- fill: Fill an input field (requires selector and value parameters)
|
|
62
|
+
- submit: Submit a form (requires selector parameter)
|
|
63
|
+
- url (optional): URL to navigate to (required for navigate action, must be a valid URL)
|
|
64
|
+
- selector (optional): CSS selector for click/fill/submit actions
|
|
65
|
+
- value (optional): Value to fill in input fields
|
|
66
|
+
- timeout (optional): Timeout in milliseconds for actions (default: 30000)
|
|
67
|
+
|
|
68
|
+
Usage notes:
|
|
69
|
+
- The browser runs headless (no visible window)
|
|
70
|
+
- Screenshots are saved to temporary files (/tmp/browser-*.png)
|
|
71
|
+
- Content is truncated to 50000 characters max
|
|
72
|
+
- Only one browser instance is maintained per session
|
|
73
|
+
- Requires: npm install playwright-core && npx playwright install chromium
|
|
74
|
+
|
|
75
|
+
Example actions:
|
|
76
|
+
- Navigate and get content: { action: "navigate", url: "https://example.com" }
|
|
77
|
+
- Take screenshot: { action: "screenshot" }
|
|
78
|
+
- Click element: { action: "click", selector: "#submit-btn" }
|
|
79
|
+
- Fill form field: { action: "fill", selector: "input[name='email']", value: "user@example.com" }`;
|
|
80
|
+
// ---------------- 4. call() 实现 ----------------
|
|
81
|
+
async function call(input, signal) {
|
|
82
|
+
const { action, url, selector, value, timeout = 30000 } = input;
|
|
83
|
+
let page;
|
|
84
|
+
try {
|
|
85
|
+
page = await getBrowserPage();
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: `无法启动浏览器:${e instanceof Error ? e.message : String(e)}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
switch (action) {
|
|
95
|
+
case 'navigate': {
|
|
96
|
+
if (!url) {
|
|
97
|
+
return { ok: false, error: 'navigate action requires url parameter' };
|
|
98
|
+
}
|
|
99
|
+
await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
|
|
100
|
+
const content = await page.evaluate(() => document.body.innerText);
|
|
101
|
+
const screenshot = screenshotPath('browser-nav');
|
|
102
|
+
await page.screenshot({ path: screenshot });
|
|
103
|
+
const truncatedContent = content.substring(0, 50_000);
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
content: [
|
|
107
|
+
`[Navigate] ${page.url()}`,
|
|
108
|
+
`Screenshot: ${screenshot}`,
|
|
109
|
+
'',
|
|
110
|
+
'--- Page Content (first 50000 chars) ---',
|
|
111
|
+
truncatedContent,
|
|
112
|
+
content.length > 50_000 ? '\n... (truncated)' : '',
|
|
113
|
+
].join('\n'),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
case 'screenshot': {
|
|
117
|
+
const screenshot = screenshotPath('browser-screenshot');
|
|
118
|
+
await page.screenshot({ path: screenshot });
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
content: `[Screenshot] ${page.url()}\nSaved: ${screenshot}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
case 'getContent': {
|
|
125
|
+
const content = await page.evaluate(() => document.body.innerText);
|
|
126
|
+
const truncated = content.substring(0, 50_000);
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
content: [
|
|
130
|
+
`[Page Content] ${page.url()}`,
|
|
131
|
+
'',
|
|
132
|
+
truncated,
|
|
133
|
+
content.length > 50_000 ? '\n... (truncated at 50000 chars)' : '',
|
|
134
|
+
].join('\n'),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case 'click': {
|
|
138
|
+
if (!selector) {
|
|
139
|
+
return { ok: false, error: 'click action requires selector parameter' };
|
|
140
|
+
}
|
|
141
|
+
await page.click(selector, { timeout });
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
content: `[Click] ${selector}\nCurrent URL: ${page.url()}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
case 'fill': {
|
|
148
|
+
if (!selector || value === undefined) {
|
|
149
|
+
return { ok: false, error: 'fill action requires selector and value parameters' };
|
|
150
|
+
}
|
|
151
|
+
await page.fill(selector, value);
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
content: `[Fill] ${selector} = "${value}"\nCurrent URL: ${page.url()}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case 'submit': {
|
|
158
|
+
if (!selector) {
|
|
159
|
+
return { ok: false, error: 'submit action requires selector parameter' };
|
|
160
|
+
}
|
|
161
|
+
// Click and wait for navigation if it triggers one
|
|
162
|
+
await Promise.all([
|
|
163
|
+
page.waitForNavigation({ timeout }).catch(() => { }),
|
|
164
|
+
page.click(selector, { timeout }),
|
|
165
|
+
]);
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
content: `[Submit] ${selector}\nCurrent URL: ${page.url()}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
return { ok: false, error: `Unknown action: ${action}` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
177
|
+
// 截断超长错误信息
|
|
178
|
+
const truncated = message.length > DEFAULT_MAX_RESULT_SIZE_CHARS
|
|
179
|
+
? message.substring(0, DEFAULT_MAX_RESULT_SIZE_CHARS) + '\n... (truncated)'
|
|
180
|
+
: message;
|
|
181
|
+
return { ok: false, error: `[${action}] Error: ${truncated}` };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ---------------- 5. 导出 ----------------
|
|
185
|
+
export const webbrowserTool = {
|
|
186
|
+
name: 'WebBrowser',
|
|
187
|
+
description,
|
|
188
|
+
inputSchema,
|
|
189
|
+
parameters,
|
|
190
|
+
isReadOnly: false,
|
|
191
|
+
isConcurrencySafe: false,
|
|
192
|
+
maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
193
|
+
call,
|
|
194
|
+
};
|