hoomanjs 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.
- package/.github/screenshot.png +0 -0
- package/.github/workflows/build-publish.yml +49 -0
- package/LICENSE +21 -0
- package/README.md +399 -0
- package/docker-compose.yml +13 -0
- package/package.json +78 -0
- package/src/acp/acp-agent.ts +803 -0
- package/src/acp/approvals.ts +147 -0
- package/src/acp/index.ts +1 -0
- package/src/acp/meta/system-prompt.ts +44 -0
- package/src/acp/meta/user-id.ts +44 -0
- package/src/acp/prompt-invoke.ts +149 -0
- package/src/acp/sessions/config-options.ts +56 -0
- package/src/acp/sessions/replay.ts +131 -0
- package/src/acp/sessions/store.ts +158 -0
- package/src/acp/sessions/title.ts +22 -0
- package/src/acp/utils/paths.ts +5 -0
- package/src/acp/utils/tool-kind.ts +38 -0
- package/src/acp/utils/tool-locations.ts +46 -0
- package/src/acp/utils/tool-result-content.ts +27 -0
- package/src/chat/app.tsx +428 -0
- package/src/chat/approvals.ts +96 -0
- package/src/chat/components/ApprovalPrompt.tsx +25 -0
- package/src/chat/components/ChatMessage.tsx +47 -0
- package/src/chat/components/Composer.tsx +39 -0
- package/src/chat/components/EmptyChatBanner.tsx +26 -0
- package/src/chat/components/ReasoningStrip.tsx +30 -0
- package/src/chat/components/Spinner.tsx +34 -0
- package/src/chat/components/StatusBar.tsx +65 -0
- package/src/chat/components/ThinkingStatus.tsx +128 -0
- package/src/chat/components/ToolEvent.tsx +34 -0
- package/src/chat/components/Transcript.tsx +34 -0
- package/src/chat/components/ascii-logo.ts +11 -0
- package/src/chat/components/shared.ts +70 -0
- package/src/chat/index.tsx +42 -0
- package/src/chat/types.ts +21 -0
- package/src/cli.ts +146 -0
- package/src/configure/app.tsx +911 -0
- package/src/configure/components/BusyScreen.tsx +22 -0
- package/src/configure/components/HomeScreen.tsx +43 -0
- package/src/configure/components/MenuScreen.tsx +44 -0
- package/src/configure/components/PromptForm.tsx +40 -0
- package/src/configure/components/SelectMenuItem.tsx +30 -0
- package/src/configure/index.tsx +43 -0
- package/src/configure/open-in-editor.ts +133 -0
- package/src/configure/types.ts +45 -0
- package/src/configure/utils.ts +113 -0
- package/src/core/agent/index.ts +76 -0
- package/src/core/config.ts +157 -0
- package/src/core/index.ts +54 -0
- package/src/core/mcp/config.ts +80 -0
- package/src/core/mcp/index.ts +13 -0
- package/src/core/mcp/manager.ts +109 -0
- package/src/core/mcp/prefixed-mcp-tool.ts +45 -0
- package/src/core/mcp/tools.ts +92 -0
- package/src/core/mcp/types.ts +37 -0
- package/src/core/memory/index.ts +17 -0
- package/src/core/memory/ltm/embed.ts +67 -0
- package/src/core/memory/ltm/index.ts +18 -0
- package/src/core/memory/ltm/store.ts +376 -0
- package/src/core/memory/ltm/tools.ts +146 -0
- package/src/core/memory/ltm/types.ts +111 -0
- package/src/core/memory/ltm/utils.ts +218 -0
- package/src/core/memory/stm/index.ts +17 -0
- package/src/core/models/anthropic.ts +53 -0
- package/src/core/models/bedrock.ts +54 -0
- package/src/core/models/google.ts +51 -0
- package/src/core/models/index.ts +16 -0
- package/src/core/models/ollama/index.ts +13 -0
- package/src/core/models/ollama/strands-ollama.ts +439 -0
- package/src/core/models/openai.ts +12 -0
- package/src/core/prompts/index.ts +23 -0
- package/src/core/prompts/skills.ts +66 -0
- package/src/core/prompts/static/fetch.md +33 -0
- package/src/core/prompts/static/filesystem.md +38 -0
- package/src/core/prompts/static/identity.md +22 -0
- package/src/core/prompts/static/ltm.md +39 -0
- package/src/core/prompts/static/memory.md +39 -0
- package/src/core/prompts/static/shell.md +34 -0
- package/src/core/prompts/static/skills.md +19 -0
- package/src/core/prompts/static/thinking.md +27 -0
- package/src/core/prompts/system.ts +109 -0
- package/src/core/skills/index.ts +2 -0
- package/src/core/skills/registry.ts +239 -0
- package/src/core/skills/tools.ts +80 -0
- package/src/core/toolkit.ts +13 -0
- package/src/core/tools/fetch.ts +288 -0
- package/src/core/tools/filesystem.ts +747 -0
- package/src/core/tools/index.ts +5 -0
- package/src/core/tools/shell.ts +426 -0
- package/src/core/tools/thinking.ts +184 -0
- package/src/core/tools/time.ts +121 -0
- package/src/core/utils/cwd-context.ts +11 -0
- package/src/core/utils/paths.ts +28 -0
- package/src/exec/approvals.ts +85 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { tool } from "@strands-agents/sdk";
|
|
6
|
+
import { getCwd } from "../utils/cwd-context.ts";
|
|
7
|
+
import type { JSONValue } from "@strands-agents/sdk";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_READ_LIMIT = 250;
|
|
11
|
+
const DEFAULT_MAX_READ_BYTES = 1024 * 1024;
|
|
12
|
+
const DEFAULT_SEARCH_MAX_RESULTS = 500;
|
|
13
|
+
const DEFAULT_TREE_DEPTH = 4;
|
|
14
|
+
const SNIPPET_RADIUS = 3;
|
|
15
|
+
|
|
16
|
+
const EditSchema = z.object({
|
|
17
|
+
oldText: z.string().describe("Exact text to find. Must match uniquely."),
|
|
18
|
+
newText: z.string().describe("Replacement text."),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type TreeNode = {
|
|
22
|
+
name: string;
|
|
23
|
+
path: string;
|
|
24
|
+
type: "file" | "directory";
|
|
25
|
+
children?: TreeNode[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function toJsonValue(value: unknown): JSONValue {
|
|
29
|
+
return JSON.parse(JSON.stringify(value)) as JSONValue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function expandHome(inputPath: string): string {
|
|
33
|
+
if (inputPath === "~" || inputPath.startsWith("~/")) {
|
|
34
|
+
return path.join(os.homedir(), inputPath.slice(1));
|
|
35
|
+
}
|
|
36
|
+
return inputPath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeUserPath(inputPath: string): string {
|
|
40
|
+
let value = inputPath.trim().replace(/^["']|["']$/g, "");
|
|
41
|
+
|
|
42
|
+
if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(value)) {
|
|
43
|
+
const drive = value[1]!.toUpperCase();
|
|
44
|
+
value = `${drive}:${value.slice(2).replace(/\//g, "\\")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
value = expandHome(value);
|
|
48
|
+
|
|
49
|
+
return path.isAbsolute(value)
|
|
50
|
+
? path.resolve(value)
|
|
51
|
+
: path.resolve(getCwd(), value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeForGlob(inputPath: string): string {
|
|
55
|
+
return inputPath.replace(/\\/g, "/");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function globToRegExp(pattern: string): RegExp {
|
|
59
|
+
const normalized = normalizeForGlob(pattern);
|
|
60
|
+
let regex = "^";
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < normalized.length; i += 1) {
|
|
63
|
+
const char = normalized[i]!;
|
|
64
|
+
const next = normalized[i + 1];
|
|
65
|
+
|
|
66
|
+
if (char === "*" && next === "*") {
|
|
67
|
+
regex += ".*";
|
|
68
|
+
i += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (char === "*") {
|
|
73
|
+
regex += "[^/]*";
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (char === "?") {
|
|
78
|
+
regex += "[^/]";
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
regex += /[\\^$+?.()|[\]{}]/.test(char) ? `\\${char}` : char;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
regex += "$";
|
|
86
|
+
return new RegExp(regex, "i");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function splitLines(content: string): string[] {
|
|
90
|
+
return content.split(/\r?\n/);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function countLines(content: string): number {
|
|
94
|
+
if (content.length === 0) {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
return splitLines(content).length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function makeLineExcerpt(
|
|
101
|
+
content: string,
|
|
102
|
+
offset = 1,
|
|
103
|
+
limit = DEFAULT_READ_LIMIT,
|
|
104
|
+
): {
|
|
105
|
+
content: string;
|
|
106
|
+
startLine: number;
|
|
107
|
+
endLine: number;
|
|
108
|
+
totalLines: number;
|
|
109
|
+
truncated: boolean;
|
|
110
|
+
} {
|
|
111
|
+
const lines = splitLines(content);
|
|
112
|
+
const totalLines = lines.length;
|
|
113
|
+
const startIndex = Math.max(0, offset - 1);
|
|
114
|
+
const selected = lines.slice(startIndex, startIndex + limit);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
content: selected.join("\n"),
|
|
118
|
+
startLine: startIndex + 1,
|
|
119
|
+
endLine: startIndex + selected.length,
|
|
120
|
+
totalLines,
|
|
121
|
+
truncated: startIndex + selected.length < totalLines,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isProbablyBinary(buffer: Buffer): boolean {
|
|
126
|
+
if (buffer.length === 0) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let suspicious = 0;
|
|
131
|
+
const sample = Math.min(buffer.length, 8000);
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < sample; i += 1) {
|
|
134
|
+
const byte = buffer[i]!;
|
|
135
|
+
|
|
136
|
+
if (byte === 0) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const isTextByte =
|
|
141
|
+
byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126);
|
|
142
|
+
|
|
143
|
+
if (!isTextByte) {
|
|
144
|
+
suspicious += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return suspicious / sample > 0.3;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function ensureExists(filePath: string): Promise<void> {
|
|
152
|
+
await fs.access(filePath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function ensureDirectory(filePath: string): Promise<void> {
|
|
156
|
+
const stat = await fs.stat(filePath);
|
|
157
|
+
if (!stat.isDirectory()) {
|
|
158
|
+
throw new Error(`Path is not a directory: ${filePath}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function ensureFile(filePath: string): Promise<void> {
|
|
163
|
+
const stat = await fs.stat(filePath);
|
|
164
|
+
if (!stat.isFile()) {
|
|
165
|
+
throw new Error(`Path is not a file: ${filePath}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readTextFile(
|
|
170
|
+
filePath: string,
|
|
171
|
+
options?: { offset?: number; limit?: number; maxBytes?: number },
|
|
172
|
+
): Promise<{
|
|
173
|
+
path: string;
|
|
174
|
+
content: string;
|
|
175
|
+
startLine: number;
|
|
176
|
+
endLine: number;
|
|
177
|
+
totalLines: number;
|
|
178
|
+
truncated: boolean;
|
|
179
|
+
sizeBytes: number;
|
|
180
|
+
}> {
|
|
181
|
+
await ensureFile(filePath);
|
|
182
|
+
const stat = await fs.stat(filePath);
|
|
183
|
+
|
|
184
|
+
if (stat.size > (options?.maxBytes ?? DEFAULT_MAX_READ_BYTES)) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`File too large to read safely (${stat.size} bytes). Use a narrower read or another tool.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const buffer = await fs.readFile(filePath);
|
|
191
|
+
if (isProbablyBinary(buffer)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
"File appears to be binary. Use get_file_info or read_file with as_base64 if you extend the tool for binary reads.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const excerpt = makeLineExcerpt(
|
|
198
|
+
buffer.toString("utf8"),
|
|
199
|
+
options?.offset,
|
|
200
|
+
options?.limit,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
path: filePath,
|
|
205
|
+
content: excerpt.content,
|
|
206
|
+
startLine: excerpt.startLine,
|
|
207
|
+
endLine: excerpt.endLine,
|
|
208
|
+
totalLines: excerpt.totalLines,
|
|
209
|
+
truncated: excerpt.truncated,
|
|
210
|
+
sizeBytes: stat.size,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function readBinaryFile(filePath: string): Promise<{
|
|
215
|
+
path: string;
|
|
216
|
+
encoding: "base64";
|
|
217
|
+
content: string;
|
|
218
|
+
sizeBytes: number;
|
|
219
|
+
}> {
|
|
220
|
+
await ensureFile(filePath);
|
|
221
|
+
const buffer = await fs.readFile(filePath);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
path: filePath,
|
|
225
|
+
encoding: "base64",
|
|
226
|
+
content: buffer.toString("base64"),
|
|
227
|
+
sizeBytes: buffer.length,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function snippetAroundChange(
|
|
232
|
+
content: string,
|
|
233
|
+
index: number,
|
|
234
|
+
replacementLength: number,
|
|
235
|
+
): string {
|
|
236
|
+
const before = content.slice(0, index);
|
|
237
|
+
const startLine = Math.max(0, countLines(before) - SNIPPET_RADIUS);
|
|
238
|
+
const endLine = countLines(before) + replacementLength + SNIPPET_RADIUS;
|
|
239
|
+
const lines = splitLines(content);
|
|
240
|
+
return lines.slice(startLine, endLine).join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function applyEdits(
|
|
244
|
+
original: string,
|
|
245
|
+
edits: Array<z.infer<typeof EditSchema>>,
|
|
246
|
+
): {
|
|
247
|
+
content: string;
|
|
248
|
+
replacements: Array<{ index: number; snippet: string }>;
|
|
249
|
+
} {
|
|
250
|
+
let current = original;
|
|
251
|
+
const replacements: Array<{ index: number; snippet: string }> = [];
|
|
252
|
+
|
|
253
|
+
for (const edit of edits) {
|
|
254
|
+
const matches = [
|
|
255
|
+
...current.matchAll(new RegExp(escapeRegExp(edit.oldText), "g")),
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
if (matches.length === 0) {
|
|
259
|
+
throw new Error(`Could not find edit target:\n${edit.oldText}`);
|
|
260
|
+
}
|
|
261
|
+
if (matches.length > 1) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Edit target is ambiguous and appears ${matches.length} times:\n${edit.oldText}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const match = matches[0]!;
|
|
268
|
+
const index = match.index ?? -1;
|
|
269
|
+
current =
|
|
270
|
+
current.slice(0, index) +
|
|
271
|
+
edit.newText +
|
|
272
|
+
current.slice(index + edit.oldText.length);
|
|
273
|
+
|
|
274
|
+
replacements.push({
|
|
275
|
+
index,
|
|
276
|
+
snippet: snippetAroundChange(current, index, countLines(edit.newText)),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { content: current, replacements };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function escapeRegExp(value: string): string {
|
|
284
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function walkDirectory(
|
|
288
|
+
dirPath: string,
|
|
289
|
+
options?: {
|
|
290
|
+
recursive?: boolean;
|
|
291
|
+
maxDepth?: number;
|
|
292
|
+
excludePatterns?: string[];
|
|
293
|
+
},
|
|
294
|
+
): Promise<Array<{ path: string; type: "file" | "directory"; size?: number }>> {
|
|
295
|
+
const results: Array<{
|
|
296
|
+
path: string;
|
|
297
|
+
type: "file" | "directory";
|
|
298
|
+
size?: number;
|
|
299
|
+
}> = [];
|
|
300
|
+
const recursive = options?.recursive ?? false;
|
|
301
|
+
const maxDepth = options?.maxDepth ?? DEFAULT_TREE_DEPTH;
|
|
302
|
+
const excludes = (options?.excludePatterns ?? []).map(globToRegExp);
|
|
303
|
+
|
|
304
|
+
async function visit(currentPath: string, depth: number): Promise<void> {
|
|
305
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
306
|
+
|
|
307
|
+
for (const entry of entries) {
|
|
308
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
309
|
+
const relative = normalizeForGlob(
|
|
310
|
+
path.relative(dirPath, fullPath) || entry.name,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
if (excludes.some((pattern) => pattern.test(relative))) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (entry.isDirectory()) {
|
|
318
|
+
results.push({ path: fullPath, type: "directory" });
|
|
319
|
+
|
|
320
|
+
if (recursive && depth < maxDepth) {
|
|
321
|
+
await visit(fullPath, depth + 1);
|
|
322
|
+
}
|
|
323
|
+
} else if (entry.isFile()) {
|
|
324
|
+
const stat = await fs.stat(fullPath);
|
|
325
|
+
results.push({ path: fullPath, type: "file", size: stat.size });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await visit(dirPath, 1);
|
|
331
|
+
return results;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function buildTree(
|
|
335
|
+
rootPath: string,
|
|
336
|
+
options?: { maxDepth?: number; excludePatterns?: string[] },
|
|
337
|
+
): Promise<TreeNode> {
|
|
338
|
+
const excludes = (options?.excludePatterns ?? []).map(globToRegExp);
|
|
339
|
+
const maxDepth = options?.maxDepth ?? DEFAULT_TREE_DEPTH;
|
|
340
|
+
|
|
341
|
+
async function build(currentPath: string, depth: number): Promise<TreeNode> {
|
|
342
|
+
const stat = await fs.stat(currentPath);
|
|
343
|
+
const node: TreeNode = {
|
|
344
|
+
name: path.basename(currentPath) || currentPath,
|
|
345
|
+
path: currentPath,
|
|
346
|
+
type: stat.isDirectory() ? "directory" : "file",
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
if (!stat.isDirectory() || depth >= maxDepth) {
|
|
350
|
+
return node;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
354
|
+
const children: TreeNode[] = [];
|
|
355
|
+
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
358
|
+
const relative = normalizeForGlob(
|
|
359
|
+
path.relative(rootPath, fullPath) || entry.name,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (excludes.some((pattern) => pattern.test(relative))) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
children.push(await build(fullPath, depth + 1));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
node.children = children;
|
|
370
|
+
return node;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return build(rootPath, 1);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function searchFiles(
|
|
377
|
+
rootPath: string,
|
|
378
|
+
pattern: string,
|
|
379
|
+
options?: { excludePatterns?: string[]; maxResults?: number },
|
|
380
|
+
): Promise<string[]> {
|
|
381
|
+
const matcher = globToRegExp(pattern);
|
|
382
|
+
const excludes = (options?.excludePatterns ?? []).map(globToRegExp);
|
|
383
|
+
const maxResults = options?.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS;
|
|
384
|
+
const results: string[] = [];
|
|
385
|
+
|
|
386
|
+
async function visit(currentPath: string): Promise<void> {
|
|
387
|
+
if (results.length >= maxResults) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
392
|
+
|
|
393
|
+
for (const entry of entries) {
|
|
394
|
+
if (results.length >= maxResults) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
399
|
+
const relative = normalizeForGlob(
|
|
400
|
+
path.relative(rootPath, fullPath) || entry.name,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
if (excludes.some((exclude) => exclude.test(relative))) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (matcher.test(relative) || matcher.test(entry.name)) {
|
|
408
|
+
results.push(fullPath);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (entry.isDirectory()) {
|
|
412
|
+
await visit(fullPath);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await visit(rootPath);
|
|
418
|
+
return results;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function createFilesystemSchema() {
|
|
422
|
+
return {
|
|
423
|
+
readFile: z.object({
|
|
424
|
+
path: z.string().describe("File path to read."),
|
|
425
|
+
offset: z
|
|
426
|
+
.number()
|
|
427
|
+
.int()
|
|
428
|
+
.min(1)
|
|
429
|
+
.optional()
|
|
430
|
+
.describe("1-indexed starting line."),
|
|
431
|
+
limit: z
|
|
432
|
+
.number()
|
|
433
|
+
.int()
|
|
434
|
+
.min(1)
|
|
435
|
+
.optional()
|
|
436
|
+
.describe("Maximum number of lines to read."),
|
|
437
|
+
binary: z
|
|
438
|
+
.boolean()
|
|
439
|
+
.optional()
|
|
440
|
+
.describe("Return file as base64 instead of UTF-8 text."),
|
|
441
|
+
}),
|
|
442
|
+
readMultipleFiles: z.object({
|
|
443
|
+
paths: z.array(z.string()).min(1).describe("List of file paths to read."),
|
|
444
|
+
offset: z.number().int().min(1).optional(),
|
|
445
|
+
limit: z.number().int().min(1).optional(),
|
|
446
|
+
}),
|
|
447
|
+
writeFile: z.object({
|
|
448
|
+
path: z.string().describe("File path to write."),
|
|
449
|
+
content: z.string().describe("Content to write."),
|
|
450
|
+
append: z.boolean().optional().describe("Append instead of overwrite."),
|
|
451
|
+
create_parents: z
|
|
452
|
+
.boolean()
|
|
453
|
+
.optional()
|
|
454
|
+
.describe("Create parent directories if needed."),
|
|
455
|
+
}),
|
|
456
|
+
editFile: z.object({
|
|
457
|
+
path: z.string().describe("File path to edit."),
|
|
458
|
+
edits: z
|
|
459
|
+
.array(EditSchema)
|
|
460
|
+
.min(1)
|
|
461
|
+
.describe("Exact text replacements to apply in order."),
|
|
462
|
+
dry_run: z
|
|
463
|
+
.boolean()
|
|
464
|
+
.optional()
|
|
465
|
+
.describe("Preview edits without writing the file."),
|
|
466
|
+
}),
|
|
467
|
+
createDirectory: z.object({
|
|
468
|
+
path: z.string().describe("Directory path to create."),
|
|
469
|
+
recursive: z
|
|
470
|
+
.boolean()
|
|
471
|
+
.optional()
|
|
472
|
+
.describe("Create parent directories too."),
|
|
473
|
+
}),
|
|
474
|
+
listDirectory: z.object({
|
|
475
|
+
path: z.string().describe("Directory path to list."),
|
|
476
|
+
recursive: z.boolean().optional().describe("List recursively."),
|
|
477
|
+
max_depth: z
|
|
478
|
+
.number()
|
|
479
|
+
.int()
|
|
480
|
+
.min(1)
|
|
481
|
+
.optional()
|
|
482
|
+
.describe("Maximum recursion depth."),
|
|
483
|
+
exclude_patterns: z
|
|
484
|
+
.array(z.string())
|
|
485
|
+
.optional()
|
|
486
|
+
.describe("Glob-style exclude patterns."),
|
|
487
|
+
}),
|
|
488
|
+
directoryTree: z.object({
|
|
489
|
+
path: z.string().describe("Directory path to render as a tree."),
|
|
490
|
+
max_depth: z.number().int().min(1).optional(),
|
|
491
|
+
exclude_patterns: z.array(z.string()).optional(),
|
|
492
|
+
}),
|
|
493
|
+
moveFile: z.object({
|
|
494
|
+
source: z.string().describe("Source file or directory."),
|
|
495
|
+
destination: z.string().describe("Destination path."),
|
|
496
|
+
overwrite: z
|
|
497
|
+
.boolean()
|
|
498
|
+
.optional()
|
|
499
|
+
.describe("Overwrite destination if it exists."),
|
|
500
|
+
}),
|
|
501
|
+
searchFiles: z.object({
|
|
502
|
+
path: z.string().describe("Root directory to search."),
|
|
503
|
+
pattern: z
|
|
504
|
+
.string()
|
|
505
|
+
.describe("Glob-style pattern, e.g. '**/*.ts' or '*.md'."),
|
|
506
|
+
exclude_patterns: z.array(z.string()).optional(),
|
|
507
|
+
max_results: z.number().int().min(1).optional(),
|
|
508
|
+
}),
|
|
509
|
+
getFileInfo: z.object({
|
|
510
|
+
path: z.string().describe("File or directory path."),
|
|
511
|
+
}),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function createFilesystemTools() {
|
|
516
|
+
const schema = createFilesystemSchema();
|
|
517
|
+
|
|
518
|
+
return [
|
|
519
|
+
tool({
|
|
520
|
+
name: "read_file",
|
|
521
|
+
description:
|
|
522
|
+
"Read a text file with optional line offset/limit. For binary files, enable the `binary` option to return base64.",
|
|
523
|
+
inputSchema: schema.readFile,
|
|
524
|
+
callback: async (input) => {
|
|
525
|
+
const filePath = normalizeUserPath(input.path);
|
|
526
|
+
const result = input.binary
|
|
527
|
+
? await readBinaryFile(filePath)
|
|
528
|
+
: await readTextFile(filePath, {
|
|
529
|
+
offset: input.offset,
|
|
530
|
+
limit: input.limit,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
return toJsonValue(result);
|
|
534
|
+
},
|
|
535
|
+
}),
|
|
536
|
+
tool({
|
|
537
|
+
name: "read_multiple_files",
|
|
538
|
+
description:
|
|
539
|
+
"Read multiple text files in one call. Each file is returned independently with success or error details.",
|
|
540
|
+
inputSchema: schema.readMultipleFiles,
|
|
541
|
+
callback: async (input) => {
|
|
542
|
+
const results = await Promise.all(
|
|
543
|
+
input.paths.map(async (itemPath) => {
|
|
544
|
+
const filePath = normalizeUserPath(itemPath);
|
|
545
|
+
try {
|
|
546
|
+
const readResult = await readTextFile(filePath, {
|
|
547
|
+
offset: input.offset,
|
|
548
|
+
limit: input.limit,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
ok: true,
|
|
553
|
+
...readResult,
|
|
554
|
+
};
|
|
555
|
+
} catch (error) {
|
|
556
|
+
return {
|
|
557
|
+
path: filePath,
|
|
558
|
+
ok: false,
|
|
559
|
+
error: error instanceof Error ? error.message : String(error),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
return toJsonValue({ results });
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
tool({
|
|
569
|
+
name: "write_file",
|
|
570
|
+
description:
|
|
571
|
+
"Write text content to a file. Can overwrite or append, and can create parent directories when requested.",
|
|
572
|
+
inputSchema: schema.writeFile,
|
|
573
|
+
callback: async (input) => {
|
|
574
|
+
const filePath = normalizeUserPath(input.path);
|
|
575
|
+
|
|
576
|
+
if (input.create_parents ?? true) {
|
|
577
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (input.append) {
|
|
581
|
+
await fs.appendFile(filePath, input.content, "utf8");
|
|
582
|
+
} else {
|
|
583
|
+
await fs.writeFile(filePath, input.content, "utf8");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return toJsonValue({
|
|
587
|
+
path: filePath,
|
|
588
|
+
appended: input.append ?? false,
|
|
589
|
+
bytes_written: Buffer.byteLength(input.content, "utf8"),
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
}),
|
|
593
|
+
tool({
|
|
594
|
+
name: "edit_file",
|
|
595
|
+
description:
|
|
596
|
+
"Apply exact text replacements to a file. Fails if any replacement target is missing or ambiguous.",
|
|
597
|
+
inputSchema: schema.editFile,
|
|
598
|
+
callback: async (input) => {
|
|
599
|
+
const filePath = normalizeUserPath(input.path);
|
|
600
|
+
await ensureFile(filePath);
|
|
601
|
+
const original = await fs.readFile(filePath, "utf8");
|
|
602
|
+
const edited = applyEdits(original, input.edits);
|
|
603
|
+
|
|
604
|
+
if (!input.dry_run) {
|
|
605
|
+
await fs.writeFile(filePath, edited.content, "utf8");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return toJsonValue({
|
|
609
|
+
path: filePath,
|
|
610
|
+
dry_run: input.dry_run ?? false,
|
|
611
|
+
edits_applied: input.edits.length,
|
|
612
|
+
changed: edited.content !== original,
|
|
613
|
+
sha256_before: createHash("sha256").update(original).digest("hex"),
|
|
614
|
+
sha256_after: createHash("sha256")
|
|
615
|
+
.update(edited.content)
|
|
616
|
+
.digest("hex"),
|
|
617
|
+
previews: edited.replacements.map((item) => item.snippet),
|
|
618
|
+
});
|
|
619
|
+
},
|
|
620
|
+
}),
|
|
621
|
+
tool({
|
|
622
|
+
name: "create_directory",
|
|
623
|
+
description:
|
|
624
|
+
"Create a directory, optionally including missing parent directories.",
|
|
625
|
+
inputSchema: schema.createDirectory,
|
|
626
|
+
callback: async (input) => {
|
|
627
|
+
const dirPath = normalizeUserPath(input.path);
|
|
628
|
+
await fs.mkdir(dirPath, { recursive: input.recursive ?? true });
|
|
629
|
+
|
|
630
|
+
return toJsonValue({
|
|
631
|
+
path: dirPath,
|
|
632
|
+
recursive: input.recursive ?? true,
|
|
633
|
+
});
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
tool({
|
|
637
|
+
name: "list_directory",
|
|
638
|
+
description:
|
|
639
|
+
"List files and directories at a path, optionally recursively with depth and exclude patterns.",
|
|
640
|
+
inputSchema: schema.listDirectory,
|
|
641
|
+
callback: async (input) => {
|
|
642
|
+
const dirPath = normalizeUserPath(input.path);
|
|
643
|
+
await ensureExists(dirPath);
|
|
644
|
+
await ensureDirectory(dirPath);
|
|
645
|
+
|
|
646
|
+
const entries = await walkDirectory(dirPath, {
|
|
647
|
+
recursive: input.recursive,
|
|
648
|
+
maxDepth: input.max_depth,
|
|
649
|
+
excludePatterns: input.exclude_patterns,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return toJsonValue({
|
|
653
|
+
path: dirPath,
|
|
654
|
+
count: entries.length,
|
|
655
|
+
entries,
|
|
656
|
+
});
|
|
657
|
+
},
|
|
658
|
+
}),
|
|
659
|
+
tool({
|
|
660
|
+
name: "directory_tree",
|
|
661
|
+
description:
|
|
662
|
+
"Return a recursive JSON tree of a directory, with optional depth and exclude patterns.",
|
|
663
|
+
inputSchema: schema.directoryTree,
|
|
664
|
+
callback: async (input) => {
|
|
665
|
+
const dirPath = normalizeUserPath(input.path);
|
|
666
|
+
await ensureExists(dirPath);
|
|
667
|
+
await ensureDirectory(dirPath);
|
|
668
|
+
|
|
669
|
+
return toJsonValue(
|
|
670
|
+
await buildTree(dirPath, {
|
|
671
|
+
maxDepth: input.max_depth,
|
|
672
|
+
excludePatterns: input.exclude_patterns,
|
|
673
|
+
}),
|
|
674
|
+
);
|
|
675
|
+
},
|
|
676
|
+
}),
|
|
677
|
+
tool({
|
|
678
|
+
name: "move_file",
|
|
679
|
+
description:
|
|
680
|
+
"Move or rename a file or directory. Can overwrite the destination if explicitly enabled.",
|
|
681
|
+
inputSchema: schema.moveFile,
|
|
682
|
+
callback: async (input) => {
|
|
683
|
+
const source = normalizeUserPath(input.source);
|
|
684
|
+
const destination = normalizeUserPath(input.destination);
|
|
685
|
+
await ensureExists(source);
|
|
686
|
+
|
|
687
|
+
if (input.overwrite) {
|
|
688
|
+
await fs.rm(destination, { recursive: true, force: true });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
692
|
+
await fs.rename(source, destination);
|
|
693
|
+
|
|
694
|
+
return toJsonValue({
|
|
695
|
+
source,
|
|
696
|
+
destination,
|
|
697
|
+
overwritten: input.overwrite ?? false,
|
|
698
|
+
});
|
|
699
|
+
},
|
|
700
|
+
}),
|
|
701
|
+
tool({
|
|
702
|
+
name: "search_files",
|
|
703
|
+
description:
|
|
704
|
+
"Recursively search for files and directories under a root path using glob-style matching.",
|
|
705
|
+
inputSchema: schema.searchFiles,
|
|
706
|
+
callback: async (input) => {
|
|
707
|
+
const rootPath = normalizeUserPath(input.path);
|
|
708
|
+
await ensureExists(rootPath);
|
|
709
|
+
await ensureDirectory(rootPath);
|
|
710
|
+
|
|
711
|
+
const matches = await searchFiles(rootPath, input.pattern, {
|
|
712
|
+
excludePatterns: input.exclude_patterns,
|
|
713
|
+
maxResults: input.max_results,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
return toJsonValue({
|
|
717
|
+
path: rootPath,
|
|
718
|
+
pattern: input.pattern,
|
|
719
|
+
count: matches.length,
|
|
720
|
+
matches,
|
|
721
|
+
});
|
|
722
|
+
},
|
|
723
|
+
}),
|
|
724
|
+
tool({
|
|
725
|
+
name: "get_file_info",
|
|
726
|
+
description:
|
|
727
|
+
"Get metadata for a file or directory, including timestamps, size, type, and permissions.",
|
|
728
|
+
inputSchema: schema.getFileInfo,
|
|
729
|
+
callback: async (input) => {
|
|
730
|
+
const filePath = normalizeUserPath(input.path);
|
|
731
|
+
const stat = await fs.stat(filePath);
|
|
732
|
+
|
|
733
|
+
return toJsonValue({
|
|
734
|
+
path: filePath,
|
|
735
|
+
name: path.basename(filePath),
|
|
736
|
+
size_bytes: stat.size,
|
|
737
|
+
created_at: stat.birthtime.toISOString(),
|
|
738
|
+
modified_at: stat.mtime.toISOString(),
|
|
739
|
+
accessed_at: stat.atime.toISOString(),
|
|
740
|
+
is_file: stat.isFile(),
|
|
741
|
+
is_directory: stat.isDirectory(),
|
|
742
|
+
permissions_octal: stat.mode.toString(8).slice(-3),
|
|
743
|
+
});
|
|
744
|
+
},
|
|
745
|
+
}),
|
|
746
|
+
];
|
|
747
|
+
}
|