@hemansubedi/aether-ai 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/.gitattributes +3 -0
- package/.github/workflows/live-stats.yml +42 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/update-preview.yml +41 -0
- package/INSTALL.md +59 -0
- package/LICENSE +21 -0
- package/README.md +397 -0
- package/assets/aether-arena.svg +72 -0
- package/assets/aether-banner.svg +62 -0
- package/assets/aether-router.svg +129 -0
- package/dist/agent.js +125 -0
- package/dist/arena.js +486 -0
- package/dist/checkpoint.js +105 -0
- package/dist/client.js +95 -0
- package/dist/combos.js +176 -0
- package/dist/commands.js +483 -0
- package/dist/config.js +104 -0
- package/dist/cost.js +176 -0
- package/dist/git.js +52 -0
- package/dist/health.js +81 -0
- package/dist/index.js +272 -0
- package/dist/keys.js +128 -0
- package/dist/memory.js +98 -0
- package/dist/modes.js +68 -0
- package/dist/providers/index.js +32 -0
- package/dist/providers/ollama.js +206 -0
- package/dist/providers/openai-compat.js +181 -0
- package/dist/providers/openrouter.js +189 -0
- package/dist/providers/registry.js +211 -0
- package/dist/router-engine.js +200 -0
- package/dist/router.js +171 -0
- package/dist/server.js +210 -0
- package/dist/session.js +97 -0
- package/dist/settings.js +97 -0
- package/dist/skills.js +100 -0
- package/dist/tokensaver.js +50 -0
- package/dist/tools/filesystem.js +243 -0
- package/dist/tools/git.js +53 -0
- package/dist/tools/glob.js +175 -0
- package/dist/tools/grep.js +193 -0
- package/dist/tools/registry.js +39 -0
- package/dist/tools/vision.js +140 -0
- package/dist/tools/websearch.js +118 -0
- package/dist/tui.js +562 -0
- package/dist/types.js +8 -0
- package/docs/preview.txt +51 -0
- package/docs/screenshots.md +110 -0
- package/docs/stats.md +5 -0
- package/install.ps1 +170 -0
- package/install.sh +196 -0
- package/package.json +34 -0
- package/scripts/generate-stats-card.ts +62 -0
- package/scripts/patch_index.ps1 +17 -0
- package/scripts/release.sh +7 -0
- package/src/agent.ts +146 -0
- package/src/arena.ts +584 -0
- package/src/checkpoint.ts +111 -0
- package/src/client.ts +172 -0
- package/src/combos.ts +199 -0
- package/src/commands.ts +973 -0
- package/src/config.ts +122 -0
- package/src/cost.ts +206 -0
- package/src/git.ts +68 -0
- package/src/health.ts +90 -0
- package/src/index.ts +281 -0
- package/src/keys.ts +135 -0
- package/src/memory.ts +101 -0
- package/src/modes.ts +84 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/ollama.ts +222 -0
- package/src/providers/openai-compat.ts +188 -0
- package/src/providers/openrouter.ts +198 -0
- package/src/providers/registry.ts +223 -0
- package/src/router-engine.ts +214 -0
- package/src/router.ts +195 -0
- package/src/server.ts +242 -0
- package/src/session.ts +111 -0
- package/src/settings.ts +125 -0
- package/src/skills.ts +106 -0
- package/src/tokensaver.ts +57 -0
- package/src/tools/filesystem.ts +258 -0
- package/src/tools/git.ts +53 -0
- package/src/tools/glob.ts +180 -0
- package/src/tools/grep.ts +192 -0
- package/src/tools/registry.ts +54 -0
- package/src/tools/vision.ts +152 -0
- package/src/tools/websearch.ts +130 -0
- package/src/tui.ts +664 -0
- package/src/types.ts +77 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import type { ToolDef } from "../types.js";
|
|
5
|
+
import { Checkpoint } from "../checkpoint.js";
|
|
6
|
+
|
|
7
|
+
export interface ToolFactory {
|
|
8
|
+
def: ToolDef;
|
|
9
|
+
execute: (args: Record<string, any>) => Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_READ_CHARS = 50_000;
|
|
13
|
+
const MAX_OUTPUT_CHARS = 30_000;
|
|
14
|
+
|
|
15
|
+
function truncate(s: string, max: number): string {
|
|
16
|
+
if (s.length <= max) return s;
|
|
17
|
+
return (
|
|
18
|
+
s.slice(0, max) +
|
|
19
|
+
`\n\n[...truncated ${s.length - max} more characters...]\n`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function makeReadFileTool(rootDir: string): ToolFactory {
|
|
24
|
+
return {
|
|
25
|
+
def: {
|
|
26
|
+
name: "ReadFile",
|
|
27
|
+
description:
|
|
28
|
+
"Read the contents of a file at a given relative path. Returns file contents.",
|
|
29
|
+
parameters: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
path: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "relative path from project root",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ["path"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
execute: async (args) => {
|
|
41
|
+
const rel = String(args.path ?? "");
|
|
42
|
+
const target = path.resolve(rootDir, rel);
|
|
43
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
44
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(target)) {
|
|
48
|
+
return `ERROR: file not found: ${rel}`;
|
|
49
|
+
}
|
|
50
|
+
if (fs.statSync(target).isDirectory()) {
|
|
51
|
+
return `ERROR: path "${rel}" is a directory, not a file`;
|
|
52
|
+
}
|
|
53
|
+
const content = fs.readFileSync(target, "utf8");
|
|
54
|
+
return truncate(content, MAX_READ_CHARS);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
57
|
+
return `ERROR reading "${rel}": ${message}`;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function makeWriteFileTool(rootDir: string): ToolFactory {
|
|
64
|
+
return {
|
|
65
|
+
def: {
|
|
66
|
+
name: "WriteFile",
|
|
67
|
+
description:
|
|
68
|
+
"Write content to a file, creating parent directories if needed. Returns confirmation.",
|
|
69
|
+
parameters: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
path: { type: "string" },
|
|
73
|
+
content: { type: "string" },
|
|
74
|
+
},
|
|
75
|
+
required: ["path", "content"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
execute: async (args) => {
|
|
79
|
+
const rel = String(args.path ?? "");
|
|
80
|
+
const content = String(args.content ?? "");
|
|
81
|
+
const target = path.resolve(rootDir, rel);
|
|
82
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
83
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
try { Checkpoint.instance().autoCheckpoint(rootDir, rel); } catch {}
|
|
87
|
+
const parent = path.dirname(target);
|
|
88
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
89
|
+
fs.writeFileSync(target, content, "utf8");
|
|
90
|
+
return `Wrote ${Buffer.byteLength(content, "utf8")} bytes to ${rel}`;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
+
return `ERROR writing "${rel}": ${message}`;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function makeEditFileTool(rootDir: string): ToolFactory {
|
|
100
|
+
return {
|
|
101
|
+
def: {
|
|
102
|
+
name: "EditFile",
|
|
103
|
+
description:
|
|
104
|
+
"Replace exact text in a file. oldText must match exactly once. Returns confirmation.",
|
|
105
|
+
parameters: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
path: { type: "string" },
|
|
109
|
+
oldText: { type: "string" },
|
|
110
|
+
newText: { type: "string" },
|
|
111
|
+
},
|
|
112
|
+
required: ["path", "oldText", "newText"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
execute: async (args) => {
|
|
116
|
+
const rel = String(args.path ?? "");
|
|
117
|
+
const oldText = String(args.oldText ?? "");
|
|
118
|
+
const newText = String(args.newText ?? "");
|
|
119
|
+
const target = path.resolve(rootDir, rel);
|
|
120
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
121
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
if (!fs.existsSync(target)) {
|
|
125
|
+
return `ERROR: file not found: ${rel}`;
|
|
126
|
+
}
|
|
127
|
+
try { Checkpoint.instance().autoCheckpoint(rootDir, rel); } catch {}
|
|
128
|
+
const original = fs.readFileSync(target, "utf8");
|
|
129
|
+
const first = original.indexOf(oldText);
|
|
130
|
+
if (first === -1) {
|
|
131
|
+
return `ERROR: oldText not found in "${rel}". The file may have changed; read it first.`;
|
|
132
|
+
}
|
|
133
|
+
const second = original.indexOf(oldText, first + 1);
|
|
134
|
+
if (second !== -1) {
|
|
135
|
+
return `ERROR: oldText found multiple times in "${rel}". oldText must match exactly once.`;
|
|
136
|
+
}
|
|
137
|
+
const updated =
|
|
138
|
+
original.slice(0, first) +
|
|
139
|
+
newText +
|
|
140
|
+
original.slice(first + oldText.length);
|
|
141
|
+
fs.writeFileSync(target, updated, "utf8");
|
|
142
|
+
return `Edited ${rel}: ${countLines(oldText)} old line(s) -> ${countLines(newText)} new line(s)`;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
return `ERROR editing "${rel}": ${message}`;
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function countLines(s: string): number {
|
|
152
|
+
if (s.length === 0) return 0;
|
|
153
|
+
return s.split("\n").length;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function makeListDirTool(rootDir: string): ToolFactory {
|
|
157
|
+
return {
|
|
158
|
+
def: {
|
|
159
|
+
name: "ListDir",
|
|
160
|
+
description:
|
|
161
|
+
"List files and subdirectories at a path (recursive, like tree).",
|
|
162
|
+
parameters: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
path: { type: "string", default: "." },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
execute: async (args) => {
|
|
170
|
+
const rel = String(args.path ?? ".");
|
|
171
|
+
const target = path.resolve(rootDir, rel);
|
|
172
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
173
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
if (!fs.existsSync(target)) {
|
|
177
|
+
return `ERROR: path not found: ${rel}`;
|
|
178
|
+
}
|
|
179
|
+
const lines: string[] = [];
|
|
180
|
+
const rootLabel = rel === "." ? "." : rel;
|
|
181
|
+
lines.push(rootLabel);
|
|
182
|
+
walk(target, "", lines);
|
|
183
|
+
return truncate(lines.join("\n"), MAX_OUTPUT_CHARS);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
186
|
+
return `ERROR listing "${rel}": ${message}`;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function walk(dir: string, prefix: string, lines: string[]): void {
|
|
193
|
+
let entries: fs.Dirent[];
|
|
194
|
+
try {
|
|
195
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
196
|
+
} catch {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
200
|
+
const filtered = entries.filter((e) => !e.name.startsWith("."));
|
|
201
|
+
filtered.forEach((entry, i) => {
|
|
202
|
+
const isLast = i === filtered.length - 1;
|
|
203
|
+
const connector = isLast ? "+-- " : "+-- ";
|
|
204
|
+
lines.push(`${prefix}${connector}${entry.name}`);
|
|
205
|
+
if (entry.isDirectory()) {
|
|
206
|
+
walk(path.join(dir, entry.name), prefix + (isLast ? " " : "� "), lines);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function makeBashTool(rootDir: string): ToolFactory {
|
|
212
|
+
return {
|
|
213
|
+
def: {
|
|
214
|
+
name: "Bash",
|
|
215
|
+
description:
|
|
216
|
+
"Execute a shell command in the project directory. Returns stdout+stderr. Use for running commands, tests, installs. Prefer non-interactive commands.",
|
|
217
|
+
parameters: {
|
|
218
|
+
type: "object",
|
|
219
|
+
properties: {
|
|
220
|
+
command: {
|
|
221
|
+
type: "string",
|
|
222
|
+
description: "the shell command",
|
|
223
|
+
},
|
|
224
|
+
timeoutMs: { type: "number", default: 30000 },
|
|
225
|
+
},
|
|
226
|
+
required: ["command"],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
execute: async (args) => {
|
|
230
|
+
const command = String(args.command ?? "");
|
|
231
|
+
if (!command.trim()) {
|
|
232
|
+
return "ERROR: empty command";
|
|
233
|
+
}
|
|
234
|
+
const timeoutMs = Math.max(
|
|
235
|
+
1000,
|
|
236
|
+
Math.min(600_000, Number(args.timeoutMs) || 30_000)
|
|
237
|
+
);
|
|
238
|
+
try {
|
|
239
|
+
const stdout = execSync(command, {
|
|
240
|
+
cwd: rootDir,
|
|
241
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
242
|
+
timeout: timeoutMs,
|
|
243
|
+
encoding: "utf8",
|
|
244
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
245
|
+
});
|
|
246
|
+
return truncate(stdout, MAX_OUTPUT_CHARS);
|
|
247
|
+
} catch (err: any) {
|
|
248
|
+
const stderr = err?.stderr ? String(err.stderr) : "";
|
|
249
|
+
const message = err?.message ? String(err.message) : String(err);
|
|
250
|
+
const stdout = err?.stdout ? String(err.stdout) : "";
|
|
251
|
+
return truncate(
|
|
252
|
+
`ERROR: ${stderr || message}\nSTDOUT: ${stdout}`,
|
|
253
|
+
MAX_OUTPUT_CHARS
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
package/src/tools/git.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { GitTool } from "../git.js";
|
|
3
|
+
|
|
4
|
+
export function makeGitTool(rootDir: string) {
|
|
5
|
+
return {
|
|
6
|
+
def: {
|
|
7
|
+
name: "Git",
|
|
8
|
+
description: "Run git operations: status, diff, commit, log, branch. Use this to inspect version control state or make commits.",
|
|
9
|
+
parameters: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
action: { type: "string", enum: ["status", "diff", "commit", "log", "branch"], description: "The git operation to perform" },
|
|
13
|
+
message: { type: "string", description: "Commit message (required for commit)" },
|
|
14
|
+
file: { type: "string", description: "Optional file path to limit diff" },
|
|
15
|
+
count: { type: "number", description: "Number of commits to show (for log)", default: 5 }
|
|
16
|
+
},
|
|
17
|
+
required: ["action"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
execute: async (args: Record<string, any>) => {
|
|
21
|
+
if (!GitTool.isRepo(rootDir)) {
|
|
22
|
+
return `ERROR: ${rootDir} is not a git repository.`;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
switch (args.action) {
|
|
26
|
+
case "status": {
|
|
27
|
+
const files = await GitTool.status(rootDir);
|
|
28
|
+
if (files.length === 0) return "Working tree clean.";
|
|
29
|
+
return files.map((f) => `${f.status}\t${f.file}`).join("\n");
|
|
30
|
+
}
|
|
31
|
+
case "diff": {
|
|
32
|
+
const d = await GitTool.diff(rootDir, args.file);
|
|
33
|
+
return d || "(no differences)";
|
|
34
|
+
}
|
|
35
|
+
case "commit": {
|
|
36
|
+
return await GitTool.commit(rootDir, args.message || "");
|
|
37
|
+
}
|
|
38
|
+
case "log": {
|
|
39
|
+
const commits = await GitTool.log(rootDir, args.count ?? 5);
|
|
40
|
+
return commits.map((c) => `${c.hash} ${c.date} ${c.message}`).join("\n");
|
|
41
|
+
}
|
|
42
|
+
case "branch": {
|
|
43
|
+
return `Current branch: ${await GitTool.branch(rootDir)}`;
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
return `ERROR: unknown git action "${args.action}"`;
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return `ERROR: ${(err as Error).message}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ToolDef } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export interface ToolFactory {
|
|
6
|
+
def: ToolDef;
|
|
7
|
+
execute: (args: Record<string, any>) => Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_RESULTS = 5000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Match a single path segment against a glob segment.
|
|
14
|
+
* - `*` matches any characters except `/`
|
|
15
|
+
* - `**` matches any number of path segments (including zero)
|
|
16
|
+
* - `?` matches exactly one character except `/`
|
|
17
|
+
* - `[...]` character class
|
|
18
|
+
*/
|
|
19
|
+
function matchSegment(segment: string, value: string): boolean {
|
|
20
|
+
if (segment === "**") return true;
|
|
21
|
+
if (segment === value) return true;
|
|
22
|
+
|
|
23
|
+
// Build a regex from the segment.
|
|
24
|
+
let re = "";
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < segment.length) {
|
|
27
|
+
const ch = segment[i];
|
|
28
|
+
if (ch === "*") {
|
|
29
|
+
if (segment[i + 1] === "*") {
|
|
30
|
+
// `**` within a segment means match anything including /
|
|
31
|
+
re += ".*";
|
|
32
|
+
i += 2;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
re += "[^/]*";
|
|
36
|
+
i += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ch === "?") {
|
|
40
|
+
re += "[^/]";
|
|
41
|
+
i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ch === "[") {
|
|
45
|
+
let j = i + 1;
|
|
46
|
+
if (j < segment.length && segment[j] === "!") j++;
|
|
47
|
+
if (j < segment.length && segment[j] === "]") j++;
|
|
48
|
+
while (j < segment.length && segment[j] !== "]") j++;
|
|
49
|
+
if (j >= segment.length) {
|
|
50
|
+
// No closing bracket - treat literally.
|
|
51
|
+
re += "\\[";
|
|
52
|
+
i += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let cls = segment.slice(i + 1, j);
|
|
56
|
+
if (cls.startsWith("!")) cls = "^" + cls.slice(1);
|
|
57
|
+
re += "[" + cls + "]";
|
|
58
|
+
i = j + 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
62
|
+
i += 1;
|
|
63
|
+
}
|
|
64
|
+
return new RegExp("^" + re + "$").test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function splitPattern(pattern: string): string[] {
|
|
68
|
+
return pattern.replace(/\\/g, "/").replace(/^\.\//, "").split("/").filter((s) => s !== "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function* walkMatches(
|
|
72
|
+
segments: string[],
|
|
73
|
+
prefix: string,
|
|
74
|
+
cwd: string,
|
|
75
|
+
depth: number
|
|
76
|
+
): Generator<string> {
|
|
77
|
+
const seg = segments[depth];
|
|
78
|
+
const isLast = depth === segments.length - 1;
|
|
79
|
+
|
|
80
|
+
if (seg === "**") {
|
|
81
|
+
// `**` matches the current directory and any depth below it.
|
|
82
|
+
if (isLast) {
|
|
83
|
+
yield prefix;
|
|
84
|
+
}
|
|
85
|
+
// Recurse into the current directory.
|
|
86
|
+
yield* walkMatches(segments, prefix, cwd, depth + 1);
|
|
87
|
+
let entries: fs.Dirent[];
|
|
88
|
+
try {
|
|
89
|
+
entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
if (e.name.startsWith(".")) continue;
|
|
95
|
+
const sub = path.join(cwd, e.name);
|
|
96
|
+
const rel = prefix ? prefix + "/" + e.name : e.name;
|
|
97
|
+
if (e.isDirectory()) {
|
|
98
|
+
yield* walkMatches(segments, rel, sub, depth);
|
|
99
|
+
} else if (isLast) {
|
|
100
|
+
yield rel;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let entries: fs.Dirent[];
|
|
107
|
+
try {
|
|
108
|
+
entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
if (e.name.startsWith(".")) continue;
|
|
115
|
+
if (!matchSegment(seg, e.name)) continue;
|
|
116
|
+
const sub = path.join(cwd, e.name);
|
|
117
|
+
const rel = prefix ? prefix + "/" + e.name : e.name;
|
|
118
|
+
if (isLast) {
|
|
119
|
+
yield rel;
|
|
120
|
+
} else if (e.isDirectory()) {
|
|
121
|
+
yield* walkMatches(segments, rel, sub, depth + 1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function makeGlobTool(rootDir: string): ToolFactory {
|
|
127
|
+
return {
|
|
128
|
+
def: {
|
|
129
|
+
name: "Glob",
|
|
130
|
+
description:
|
|
131
|
+
"Find files matching a glob pattern (e.g. **/*.ts, src/*.js). Returns matching paths relative to project root, sorted.",
|
|
132
|
+
parameters: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
pattern: {
|
|
136
|
+
type: "string",
|
|
137
|
+
description: "glob pattern like **/*.ts",
|
|
138
|
+
},
|
|
139
|
+
path: {
|
|
140
|
+
type: "string",
|
|
141
|
+
default: ".",
|
|
142
|
+
description: "root dir to search",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
required: ["pattern"],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
execute: async (args) => {
|
|
149
|
+
const pattern = String(args.pattern ?? "").trim();
|
|
150
|
+
if (!pattern) {
|
|
151
|
+
return "ERROR: pattern is required";
|
|
152
|
+
}
|
|
153
|
+
const relRoot = String(args.path ?? ".").trim() || ".";
|
|
154
|
+
const target = path.resolve(rootDir, relRoot);
|
|
155
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
156
|
+
return `ERROR: path "${relRoot}" escapes the project root`;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
if (!fs.existsSync(target)) {
|
|
160
|
+
return `ERROR: path not found: ${relRoot}`;
|
|
161
|
+
}
|
|
162
|
+
const segments = splitPattern(pattern);
|
|
163
|
+
if (segments.length === 0) {
|
|
164
|
+
return "ERROR: empty pattern";
|
|
165
|
+
}
|
|
166
|
+
const results: string[] = [];
|
|
167
|
+
for (const match of walkMatches(segments, "", target, 0)) {
|
|
168
|
+
results.push(match);
|
|
169
|
+
if (results.length >= MAX_RESULTS) break;
|
|
170
|
+
}
|
|
171
|
+
results.sort();
|
|
172
|
+
if (results.length === 0) return "No matches";
|
|
173
|
+
return results.join("\n");
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
176
|
+
return `ERROR globbing "${pattern}": ${message}`;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ToolDef } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export interface ToolFactory {
|
|
6
|
+
def: ToolDef;
|
|
7
|
+
execute: (args: Record<string, any>) => Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_RESULTS = 5000;
|
|
11
|
+
const MAX_LINE_CHARS = 5000;
|
|
12
|
+
|
|
13
|
+
function matchesInclude(name: string, include: string): boolean {
|
|
14
|
+
if (!include) return true;
|
|
15
|
+
const patterns = include.split(",").map((s) => s.trim()).filter(Boolean);
|
|
16
|
+
for (const pat of patterns) {
|
|
17
|
+
if (matchGlob(pat, name)) return true;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function matchGlob(pattern: string, value: string): boolean {
|
|
23
|
+
const re = globToRegex(pattern);
|
|
24
|
+
return re.test(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function globToRegex(pattern: string): RegExp {
|
|
28
|
+
let re = "";
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < pattern.length) {
|
|
31
|
+
const ch = pattern[i];
|
|
32
|
+
if (ch === "*") {
|
|
33
|
+
if (pattern[i + 1] === "*") {
|
|
34
|
+
re += ".*";
|
|
35
|
+
i += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
re += "[^/]*";
|
|
39
|
+
i += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (ch === "?") {
|
|
43
|
+
re += "[^/]";
|
|
44
|
+
i += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === "[") {
|
|
48
|
+
let j = i + 1;
|
|
49
|
+
if (j < pattern.length && pattern[j] === "!") j++;
|
|
50
|
+
if (j < pattern.length && pattern[j] === "]") j++;
|
|
51
|
+
while (j < pattern.length && pattern[j] !== "]") j++;
|
|
52
|
+
if (j >= pattern.length) {
|
|
53
|
+
re += "\\[";
|
|
54
|
+
i += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let cls = pattern.slice(i + 1, j);
|
|
58
|
+
if (cls.startsWith("!")) cls = "^" + cls.slice(1);
|
|
59
|
+
re += "[" + cls + "]";
|
|
60
|
+
i = j + 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
return new RegExp("^" + re + "$");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isBinary(buf: Buffer): boolean {
|
|
70
|
+
// Detect null bytes (common in binary files).
|
|
71
|
+
for (let i = 0; i < Math.min(buf.length, 4096); i++) {
|
|
72
|
+
if (buf[i] === 0) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function* walkFiles(dir: string): Generator<string> {
|
|
78
|
+
let entries: fs.Dirent[];
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (e.name.startsWith(".")) continue;
|
|
86
|
+
const full = path.join(dir, e.name);
|
|
87
|
+
if (e.isDirectory()) {
|
|
88
|
+
yield* walkFiles(full);
|
|
89
|
+
} else if (e.isFile()) {
|
|
90
|
+
yield full;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function makeGrepTool(rootDir: string): ToolFactory {
|
|
96
|
+
return {
|
|
97
|
+
def: {
|
|
98
|
+
name: "Grep",
|
|
99
|
+
description:
|
|
100
|
+
"Search file contents for a regex pattern. Returns matching lines with file path and line number. Use include filter like *.ts.",
|
|
101
|
+
parameters: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
pattern: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "regex pattern",
|
|
107
|
+
},
|
|
108
|
+
path: {
|
|
109
|
+
type: "string",
|
|
110
|
+
default: ".",
|
|
111
|
+
description: "dir or file to search",
|
|
112
|
+
},
|
|
113
|
+
include: {
|
|
114
|
+
type: "string",
|
|
115
|
+
default: "",
|
|
116
|
+
description: "file filter e.g. *.ts",
|
|
117
|
+
},
|
|
118
|
+
maxResults: {
|
|
119
|
+
type: "number",
|
|
120
|
+
default: 50,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ["pattern"],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
execute: async (args) => {
|
|
127
|
+
const pattern = String(args.pattern ?? "");
|
|
128
|
+
if (!pattern) {
|
|
129
|
+
return "ERROR: pattern is required";
|
|
130
|
+
}
|
|
131
|
+
let regex: RegExp;
|
|
132
|
+
try {
|
|
133
|
+
regex = new RegExp(pattern);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
+
return `ERROR: invalid regex: ${message}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const relPath = String(args.path ?? ".").trim() || ".";
|
|
140
|
+
const target = path.resolve(rootDir, relPath);
|
|
141
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
142
|
+
return `ERROR: path "${relPath}" escapes the project root`;
|
|
143
|
+
}
|
|
144
|
+
const include = String(args.include ?? "");
|
|
145
|
+
const maxResults = Math.max(1, Math.min(MAX_RESULTS, Number(args.maxResults) || 50));
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(target)) {
|
|
149
|
+
return `ERROR: path not found: ${relPath}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const files: string[] = [];
|
|
153
|
+
if (fs.statSync(target).isFile()) {
|
|
154
|
+
files.push(target);
|
|
155
|
+
} else {
|
|
156
|
+
for (const f of walkFiles(target)) {
|
|
157
|
+
files.push(f);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const matches: string[] = [];
|
|
162
|
+
for (const full of files) {
|
|
163
|
+
const rel = path.relative(rootDir, full).replace(/\\/g, "/");
|
|
164
|
+
if (!matchesInclude(rel, include)) continue;
|
|
165
|
+
try {
|
|
166
|
+
const buf = fs.readFileSync(full);
|
|
167
|
+
if (isBinary(buf)) continue;
|
|
168
|
+
const text = buf.toString("utf8");
|
|
169
|
+
const lines = text.split("\n");
|
|
170
|
+
for (let ln = 1; ln <= lines.length; ln++) {
|
|
171
|
+
if (matches.length >= maxResults) break;
|
|
172
|
+
const line = lines[ln - 1];
|
|
173
|
+
if (line.length > MAX_LINE_CHARS) continue;
|
|
174
|
+
if (regex.test(line)) {
|
|
175
|
+
matches.push(`${rel}:${ln}: ${line}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// skip unreadable files
|
|
180
|
+
}
|
|
181
|
+
if (matches.length >= maxResults) break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (matches.length === 0) return "No matches";
|
|
185
|
+
return matches.join("\n");
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
188
|
+
return `ERROR grepping "${pattern}": ${message}`;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|