@termaxjs/web-ai 0.1.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/LICENSE +201 -0
- package/dist/config.d.ts +511 -0
- package/dist/config.js +708 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/compact.d.ts +8 -0
- package/dist/lib/compact.js +179 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/miniWindowGeometry.d.ts +17 -0
- package/dist/lib/miniWindowGeometry.js +59 -0
- package/dist/lib/redact.d.ts +1 -0
- package/dist/lib/redact.js +34 -0
- package/dist/lib/security.d.ts +72 -0
- package/dist/lib/security.js +376 -0
- package/dist/tools/agent.d.ts +4 -0
- package/dist/tools/agent.js +22 -0
- package/dist/tools/context.d.ts +30 -0
- package/dist/tools/context.js +8 -0
- package/dist/tools/edit.d.ts +27 -0
- package/dist/tools/edit.js +161 -0
- package/dist/tools/fs.d.ts +93 -0
- package/dist/tools/fs.js +215 -0
- package/dist/tools/search.d.ts +48 -0
- package/dist/tools/search.js +118 -0
- package/dist/tools/shell.d.ts +7 -0
- package/dist/tools/shell.js +37 -0
- package/dist/tools/subagent.d.ts +2 -0
- package/dist/tools/subagent.js +3 -0
- package/dist/tools/terminal.d.ts +44 -0
- package/dist/tools/terminal.js +100 -0
- package/dist/tools/todo.d.ts +2 -0
- package/dist/tools/todo.js +3 -0
- package/dist/tools/tools.d.ts +236 -0
- package/dist/tools/tools.js +42 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.js +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type ToolContext } from "./context.js";
|
|
2
|
+
export declare function buildFsTools(ctx: ToolContext): {
|
|
3
|
+
readonly read_file: import("ai").Tool<{
|
|
4
|
+
path: string;
|
|
5
|
+
offset?: number | undefined;
|
|
6
|
+
limit?: number | undefined;
|
|
7
|
+
}, {
|
|
8
|
+
error: string;
|
|
9
|
+
path: string;
|
|
10
|
+
size?: undefined;
|
|
11
|
+
unchanged?: undefined;
|
|
12
|
+
} | {
|
|
13
|
+
error: string;
|
|
14
|
+
path: string;
|
|
15
|
+
size: any;
|
|
16
|
+
unchanged?: undefined;
|
|
17
|
+
} | {
|
|
18
|
+
path: string;
|
|
19
|
+
unchanged: boolean;
|
|
20
|
+
size: any;
|
|
21
|
+
error?: undefined;
|
|
22
|
+
} | {
|
|
23
|
+
truncated?: boolean | undefined;
|
|
24
|
+
hint?: string | undefined;
|
|
25
|
+
path: string;
|
|
26
|
+
content: any;
|
|
27
|
+
size: any;
|
|
28
|
+
total_lines: any;
|
|
29
|
+
error?: undefined;
|
|
30
|
+
unchanged?: undefined;
|
|
31
|
+
} | {
|
|
32
|
+
truncated?: boolean | undefined;
|
|
33
|
+
path: string;
|
|
34
|
+
content: any;
|
|
35
|
+
size: any;
|
|
36
|
+
total_lines: any;
|
|
37
|
+
start_line: number;
|
|
38
|
+
end_line: number;
|
|
39
|
+
error?: undefined;
|
|
40
|
+
unchanged?: undefined;
|
|
41
|
+
}>;
|
|
42
|
+
readonly list_directory: import("ai").Tool<{
|
|
43
|
+
path: string;
|
|
44
|
+
}, {
|
|
45
|
+
error: string;
|
|
46
|
+
path: string;
|
|
47
|
+
entries?: undefined;
|
|
48
|
+
} | {
|
|
49
|
+
path: string;
|
|
50
|
+
entries: any;
|
|
51
|
+
error?: undefined;
|
|
52
|
+
}>;
|
|
53
|
+
readonly write_file: import("ai").Tool<{
|
|
54
|
+
path: string;
|
|
55
|
+
content: string;
|
|
56
|
+
}, {
|
|
57
|
+
error: string;
|
|
58
|
+
path: string;
|
|
59
|
+
queued_for_plan_review?: undefined;
|
|
60
|
+
ok?: undefined;
|
|
61
|
+
bytesWritten?: undefined;
|
|
62
|
+
} | {
|
|
63
|
+
path: string;
|
|
64
|
+
queued_for_plan_review: boolean;
|
|
65
|
+
ok: boolean;
|
|
66
|
+
error?: undefined;
|
|
67
|
+
bytesWritten?: undefined;
|
|
68
|
+
} | {
|
|
69
|
+
path: string;
|
|
70
|
+
bytesWritten: number;
|
|
71
|
+
ok: boolean;
|
|
72
|
+
error?: undefined;
|
|
73
|
+
queued_for_plan_review?: undefined;
|
|
74
|
+
}>;
|
|
75
|
+
readonly create_directory: import("ai").Tool<{
|
|
76
|
+
path: string;
|
|
77
|
+
}, {
|
|
78
|
+
error: string;
|
|
79
|
+
path: string;
|
|
80
|
+
queued_for_plan_review?: undefined;
|
|
81
|
+
ok?: undefined;
|
|
82
|
+
} | {
|
|
83
|
+
path: string;
|
|
84
|
+
queued_for_plan_review: boolean;
|
|
85
|
+
ok: boolean;
|
|
86
|
+
error?: undefined;
|
|
87
|
+
} | {
|
|
88
|
+
path: string;
|
|
89
|
+
ok: boolean;
|
|
90
|
+
error?: undefined;
|
|
91
|
+
queued_for_plan_review?: undefined;
|
|
92
|
+
}>;
|
|
93
|
+
};
|
package/dist/tools/fs.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getAiAdapter } from "../config.js";
|
|
4
|
+
import { checkReadableCanonical, checkWritableCanonical, } from "../lib/security.js";
|
|
5
|
+
import { resolvePath } from "./context.js";
|
|
6
|
+
const READ_BYTE_CAP = 25 * 1024;
|
|
7
|
+
const READ_LINE_CAP = 2000;
|
|
8
|
+
function djb2(s) {
|
|
9
|
+
let h = 5381;
|
|
10
|
+
for (let i = 0; i < s.length; i++)
|
|
11
|
+
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
12
|
+
return h >>> 0;
|
|
13
|
+
}
|
|
14
|
+
export function buildFsTools(ctx) {
|
|
15
|
+
return {
|
|
16
|
+
read_file: tool({
|
|
17
|
+
description: "Read a UTF-8 text file. Defaults to the first 2000 lines (capped at 25KB). Pass `offset`/`limit` for line-based windowing of large files. Refuses binary, oversized, or sensitive files (.env, keys, credentials). If you call this on the same path twice in a session without edits in between, the second call returns `unchanged: true` instead of re-emitting the content — re-read the prior tool result.",
|
|
18
|
+
inputSchema: z.object({
|
|
19
|
+
path: z
|
|
20
|
+
.string()
|
|
21
|
+
.describe("Absolute path, or relative to the active terminal cwd."),
|
|
22
|
+
offset: z
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.min(0)
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("0-based start line. Default 0."),
|
|
28
|
+
limit: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(10000)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Max lines to return. Default 2000."),
|
|
35
|
+
}),
|
|
36
|
+
execute: async ({ path, offset, limit }) => {
|
|
37
|
+
const reqPath = resolvePath(path, ctx.getCwd());
|
|
38
|
+
const safety = await checkReadableCanonical(reqPath, getAiAdapter().native.canonicalize);
|
|
39
|
+
if (!safety.ok)
|
|
40
|
+
return { error: safety.reason, path: reqPath };
|
|
41
|
+
const abs = safety.canonical;
|
|
42
|
+
try {
|
|
43
|
+
const r = await getAiAdapter().native.readFile(abs);
|
|
44
|
+
if (r.kind === "binary")
|
|
45
|
+
return { error: "binary file refused", path: abs, size: r.size };
|
|
46
|
+
if (r.kind === "toolarge")
|
|
47
|
+
return {
|
|
48
|
+
error: `file too large (${r.size} bytes, limit ${r.limit})`,
|
|
49
|
+
path: abs,
|
|
50
|
+
};
|
|
51
|
+
const hash = djb2(r.content);
|
|
52
|
+
const isFullRead = offset === undefined && limit === undefined;
|
|
53
|
+
const prior = ctx.readCache.get(abs);
|
|
54
|
+
if (isFullRead &&
|
|
55
|
+
prior &&
|
|
56
|
+
prior.size === r.size &&
|
|
57
|
+
prior.hash === hash) {
|
|
58
|
+
return { path: abs, unchanged: true, size: r.size };
|
|
59
|
+
}
|
|
60
|
+
ctx.readCache.set(abs, { size: r.size, hash });
|
|
61
|
+
if (isFullRead) {
|
|
62
|
+
const lines = r.content.split("\n");
|
|
63
|
+
const sliceEnd = Math.min(lines.length, READ_LINE_CAP);
|
|
64
|
+
let content = lines.slice(0, sliceEnd).join("\n");
|
|
65
|
+
let truncated = sliceEnd < lines.length;
|
|
66
|
+
if (content.length > READ_BYTE_CAP) {
|
|
67
|
+
content = content.slice(0, READ_BYTE_CAP);
|
|
68
|
+
truncated = true;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
path: abs,
|
|
72
|
+
content,
|
|
73
|
+
size: r.size,
|
|
74
|
+
total_lines: lines.length,
|
|
75
|
+
...(truncated
|
|
76
|
+
? {
|
|
77
|
+
truncated: true,
|
|
78
|
+
hint: "call read_file with offset to continue",
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const lines = r.content.split("\n");
|
|
84
|
+
const start = offset ?? 0;
|
|
85
|
+
const requested = limit ?? READ_LINE_CAP;
|
|
86
|
+
const end = Math.min(lines.length, start + requested);
|
|
87
|
+
let content = lines.slice(start, end).join("\n");
|
|
88
|
+
let truncated = end < lines.length;
|
|
89
|
+
if (content.length > READ_BYTE_CAP) {
|
|
90
|
+
content = content.slice(0, READ_BYTE_CAP);
|
|
91
|
+
truncated = true;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
path: abs,
|
|
95
|
+
content,
|
|
96
|
+
size: r.size,
|
|
97
|
+
total_lines: lines.length,
|
|
98
|
+
start_line: start,
|
|
99
|
+
end_line: end,
|
|
100
|
+
...(truncated ? { truncated: true } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return { error: String(e), path: abs };
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
list_directory: tool({
|
|
109
|
+
description: "List immediate entries (files + directories) in a directory. Hidden entries are omitted.",
|
|
110
|
+
inputSchema: z.object({
|
|
111
|
+
path: z
|
|
112
|
+
.string()
|
|
113
|
+
.describe("Absolute path, or relative to the active terminal cwd."),
|
|
114
|
+
}),
|
|
115
|
+
execute: async ({ path }) => {
|
|
116
|
+
const reqPath = resolvePath(path, ctx.getCwd());
|
|
117
|
+
const safety = await checkReadableCanonical(reqPath, getAiAdapter().native.canonicalize);
|
|
118
|
+
if (!safety.ok)
|
|
119
|
+
return { error: safety.reason, path: reqPath };
|
|
120
|
+
const abs = safety.canonical;
|
|
121
|
+
try {
|
|
122
|
+
const entries = await getAiAdapter().native.readDir(abs);
|
|
123
|
+
return {
|
|
124
|
+
path: abs,
|
|
125
|
+
entries: entries.map((e) => ({ name: e.name, kind: e.kind })),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
return { error: String(e), path: abs };
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
write_file: tool({
|
|
134
|
+
description: "Create or overwrite a file with the given content. Always asks the user before running. Prefer `edit` / `multi_edit` for in-place changes — only use `write_file` for creating a brand-new file or fully replacing a tiny one.",
|
|
135
|
+
inputSchema: z.object({
|
|
136
|
+
path: z.string(),
|
|
137
|
+
content: z.string(),
|
|
138
|
+
}),
|
|
139
|
+
needsApproval: true,
|
|
140
|
+
execute: async ({ path, content }) => {
|
|
141
|
+
const reqPath = resolvePath(path, ctx.getCwd());
|
|
142
|
+
const safety = await checkWritableCanonical(reqPath, getAiAdapter().native.canonicalize);
|
|
143
|
+
if (!safety.ok)
|
|
144
|
+
return { error: safety.reason, path: reqPath };
|
|
145
|
+
const abs = safety.canonical;
|
|
146
|
+
if (getAiAdapter().isPlanActive()) {
|
|
147
|
+
let original = "";
|
|
148
|
+
let isNewFile = false;
|
|
149
|
+
try {
|
|
150
|
+
const r = await getAiAdapter().native.readFile(abs);
|
|
151
|
+
if (r.kind === "text")
|
|
152
|
+
original = r.content;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
isNewFile = true;
|
|
156
|
+
}
|
|
157
|
+
getAiAdapter().enqueuePlanEdit({
|
|
158
|
+
id: crypto.randomUUID(),
|
|
159
|
+
kind: "write_file",
|
|
160
|
+
path: abs,
|
|
161
|
+
originalContent: original,
|
|
162
|
+
proposedContent: content,
|
|
163
|
+
isNewFile,
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
path: abs,
|
|
167
|
+
queued_for_plan_review: true,
|
|
168
|
+
ok: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await getAiAdapter().native.writeFile(abs, content);
|
|
173
|
+
ctx.readCache.set(abs, { size: content.length, hash: djb2(content) });
|
|
174
|
+
return { path: abs, bytesWritten: content.length, ok: true };
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return { error: String(e), path: abs };
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
create_directory: tool({
|
|
182
|
+
description: "Create a directory (and any missing parents). Always asks the user before running.",
|
|
183
|
+
inputSchema: z.object({
|
|
184
|
+
path: z.string(),
|
|
185
|
+
}),
|
|
186
|
+
needsApproval: true,
|
|
187
|
+
execute: async ({ path }) => {
|
|
188
|
+
const reqPath = resolvePath(path, ctx.getCwd());
|
|
189
|
+
const safety = await checkWritableCanonical(reqPath, getAiAdapter().native.canonicalize);
|
|
190
|
+
if (!safety.ok)
|
|
191
|
+
return { error: safety.reason, path: reqPath };
|
|
192
|
+
const abs = safety.canonical;
|
|
193
|
+
if (getAiAdapter().isPlanActive()) {
|
|
194
|
+
getAiAdapter().enqueuePlanEdit({
|
|
195
|
+
id: crypto.randomUUID(),
|
|
196
|
+
kind: "create_directory",
|
|
197
|
+
path: abs,
|
|
198
|
+
originalContent: "",
|
|
199
|
+
proposedContent: "",
|
|
200
|
+
isNewFile: true,
|
|
201
|
+
description: "Create directory",
|
|
202
|
+
});
|
|
203
|
+
return { path: abs, queued_for_plan_review: true, ok: true };
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await getAiAdapter().native.createDir(abs);
|
|
207
|
+
return { path: abs, ok: true };
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
return { error: String(e), path: abs };
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ToolContext } from "./context.js";
|
|
2
|
+
export declare function buildSearchTools(ctx: ToolContext): {
|
|
3
|
+
readonly grep: import("ai").Tool<{
|
|
4
|
+
pattern: string;
|
|
5
|
+
root?: string | undefined;
|
|
6
|
+
glob?: string[] | undefined;
|
|
7
|
+
case_insensitive?: boolean | undefined;
|
|
8
|
+
max_results?: number | undefined;
|
|
9
|
+
}, {
|
|
10
|
+
error: string;
|
|
11
|
+
root?: undefined;
|
|
12
|
+
hits?: undefined;
|
|
13
|
+
truncated?: undefined;
|
|
14
|
+
files_scanned?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
error: string;
|
|
17
|
+
root: string;
|
|
18
|
+
hits?: undefined;
|
|
19
|
+
truncated?: undefined;
|
|
20
|
+
files_scanned?: undefined;
|
|
21
|
+
} | {
|
|
22
|
+
root: string;
|
|
23
|
+
hits: any;
|
|
24
|
+
truncated: any;
|
|
25
|
+
files_scanned: any;
|
|
26
|
+
error?: undefined;
|
|
27
|
+
}>;
|
|
28
|
+
readonly glob: import("ai").Tool<{
|
|
29
|
+
pattern: string;
|
|
30
|
+
root?: string | undefined;
|
|
31
|
+
max_results?: number | undefined;
|
|
32
|
+
}, {
|
|
33
|
+
error: string;
|
|
34
|
+
root?: undefined;
|
|
35
|
+
hits?: undefined;
|
|
36
|
+
truncated?: undefined;
|
|
37
|
+
} | {
|
|
38
|
+
error: string;
|
|
39
|
+
root: string;
|
|
40
|
+
hits?: undefined;
|
|
41
|
+
truncated?: undefined;
|
|
42
|
+
} | {
|
|
43
|
+
root: string;
|
|
44
|
+
hits: any;
|
|
45
|
+
truncated: any;
|
|
46
|
+
error?: undefined;
|
|
47
|
+
}>;
|
|
48
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getAiAdapter } from "../config.js";
|
|
4
|
+
import { checkReadableCanonical } from "../lib/security.js";
|
|
5
|
+
import { resolvePath } from "./context.js";
|
|
6
|
+
function resolveRoot(rawRoot, ctx) {
|
|
7
|
+
if (rawRoot && rawRoot.trim().length > 0) {
|
|
8
|
+
try {
|
|
9
|
+
return { ok: true, path: resolvePath(rawRoot, ctx.getCwd()) };
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
return { ok: false, error: String(e) };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const ws = ctx.getWorkspaceRoot();
|
|
16
|
+
if (ws)
|
|
17
|
+
return { ok: true, path: ws };
|
|
18
|
+
const cwd = ctx.getCwd();
|
|
19
|
+
if (cwd)
|
|
20
|
+
return { ok: true, path: cwd };
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: "no workspace root or active cwd; pass `root` explicitly.",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const MAX_LINE_LEN = 160;
|
|
27
|
+
function clipLine(s) {
|
|
28
|
+
if (s.length <= MAX_LINE_LEN)
|
|
29
|
+
return s;
|
|
30
|
+
return `${s.slice(0, MAX_LINE_LEN)}…[+${s.length - MAX_LINE_LEN}]`;
|
|
31
|
+
}
|
|
32
|
+
export function buildSearchTools(ctx) {
|
|
33
|
+
return {
|
|
34
|
+
grep: tool({
|
|
35
|
+
description: "Search file contents in the workspace using a regular expression. Honors .gitignore. Returns up to `max_results` (default 30, max 500) `{path, line, text}` hits, with a `truncated` flag when more existed. Long match lines are clipped to 160 chars. Use this for code navigation — do NOT brute-force read_file across the tree. Narrow with `glob` when you can; raise `max_results` only if the first batch truly isn't enough.",
|
|
36
|
+
inputSchema: z.object({
|
|
37
|
+
pattern: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe("Regex pattern (Rust ripgrep dialect). Anchor and escape literal characters as needed."),
|
|
40
|
+
root: z
|
|
41
|
+
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Root to search under. Defaults to workspace root, then active cwd."),
|
|
44
|
+
glob: z
|
|
45
|
+
.array(z.string())
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Optional include-globs over relative paths, e.g. ['**/*.ts', 'src/**/*.tsx']."),
|
|
48
|
+
case_insensitive: z.boolean().optional(),
|
|
49
|
+
max_results: z.number().int().min(1).max(500).optional(),
|
|
50
|
+
}),
|
|
51
|
+
execute: async ({ pattern, root, glob, case_insensitive, max_results, }) => {
|
|
52
|
+
const r = resolveRoot(root, ctx);
|
|
53
|
+
if (!r.ok)
|
|
54
|
+
return { error: r.error };
|
|
55
|
+
const safety = await checkReadableCanonical(r.path, getAiAdapter().native.canonicalize);
|
|
56
|
+
if (!safety.ok)
|
|
57
|
+
return { error: safety.reason, root: r.path };
|
|
58
|
+
r.path = safety.canonical;
|
|
59
|
+
const cap = Math.min(max_results ?? 30, 500);
|
|
60
|
+
try {
|
|
61
|
+
const res = await getAiAdapter().native.grep({
|
|
62
|
+
pattern,
|
|
63
|
+
root: r.path,
|
|
64
|
+
glob,
|
|
65
|
+
caseInsensitive: case_insensitive,
|
|
66
|
+
maxResults: cap,
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
root: r.path,
|
|
70
|
+
hits: res.hits.map((h) => ({
|
|
71
|
+
path: h.path,
|
|
72
|
+
rel: h.rel,
|
|
73
|
+
line: h.line,
|
|
74
|
+
text: clipLine(h.text),
|
|
75
|
+
})),
|
|
76
|
+
truncated: res.truncated,
|
|
77
|
+
files_scanned: res.files_scanned,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
return { error: String(e), root: r.path };
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
glob: tool({
|
|
86
|
+
description: "Find files by path pattern (gitignore-aware). Use over `list_directory` when you want all matches recursively. Patterns use globset syntax: `**/*.ts`, `src/**/test_*.py`. Returns up to `max_results` matches.",
|
|
87
|
+
inputSchema: z.object({
|
|
88
|
+
pattern: z.string().describe("Glob pattern over relative paths."),
|
|
89
|
+
root: z.string().optional(),
|
|
90
|
+
max_results: z.number().int().min(1).max(2000).optional(),
|
|
91
|
+
}),
|
|
92
|
+
execute: async ({ pattern, root, max_results }) => {
|
|
93
|
+
const r = resolveRoot(root, ctx);
|
|
94
|
+
if (!r.ok)
|
|
95
|
+
return { error: r.error };
|
|
96
|
+
const safety = await checkReadableCanonical(r.path, getAiAdapter().native.canonicalize);
|
|
97
|
+
if (!safety.ok)
|
|
98
|
+
return { error: safety.reason, root: r.path };
|
|
99
|
+
r.path = safety.canonical;
|
|
100
|
+
try {
|
|
101
|
+
const res = await getAiAdapter().native.glob({
|
|
102
|
+
pattern,
|
|
103
|
+
root: r.path,
|
|
104
|
+
maxResults: max_results,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
root: r.path,
|
|
108
|
+
hits: res.hits,
|
|
109
|
+
truncated: res.truncated,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return { error: String(e), root: r.path };
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getAiAdapter } from "../config.js";
|
|
4
|
+
import { checkShellCommand } from "../lib/security.js";
|
|
5
|
+
const sessionShells = new Map();
|
|
6
|
+
async function getSessionShell(sessionId, cwd) {
|
|
7
|
+
let p = sessionShells.get(sessionId);
|
|
8
|
+
if (p)
|
|
9
|
+
return p;
|
|
10
|
+
const newP = getAiAdapter().native.shellSessionOpen(cwd);
|
|
11
|
+
sessionShells.set(sessionId, newP);
|
|
12
|
+
return newP;
|
|
13
|
+
}
|
|
14
|
+
export function buildShellTools(ctx) {
|
|
15
|
+
return {
|
|
16
|
+
bash_run: tool({
|
|
17
|
+
description: "Run a shell command.",
|
|
18
|
+
inputSchema: z.object({
|
|
19
|
+
command: z.string(),
|
|
20
|
+
timeout_secs: z.number().optional(),
|
|
21
|
+
}),
|
|
22
|
+
execute: async ({ command, timeout_secs }) => {
|
|
23
|
+
const safety = checkShellCommand(command);
|
|
24
|
+
if (!safety.ok) {
|
|
25
|
+
return { error: safety.reason };
|
|
26
|
+
}
|
|
27
|
+
const sid = ctx.getSessionId();
|
|
28
|
+
if (!sid)
|
|
29
|
+
return { error: "no active chat session" };
|
|
30
|
+
const cwd = ctx.getCwd();
|
|
31
|
+
const shellId = await getSessionShell(sid, cwd);
|
|
32
|
+
const r = await getAiAdapter().native.shellSessionRun(shellId, command, cwd, timeout_secs ?? undefined);
|
|
33
|
+
return r;
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ToolContext } from "./context.js";
|
|
2
|
+
export declare function buildTerminalTools(ctx: ToolContext): {
|
|
3
|
+
readonly suggest_command: import("ai").Tool<{
|
|
4
|
+
command: string;
|
|
5
|
+
explanation?: string | undefined;
|
|
6
|
+
}, {
|
|
7
|
+
error: string;
|
|
8
|
+
command?: undefined;
|
|
9
|
+
explanation?: undefined;
|
|
10
|
+
} | {
|
|
11
|
+
command: string;
|
|
12
|
+
explanation: string | undefined;
|
|
13
|
+
error?: undefined;
|
|
14
|
+
}>;
|
|
15
|
+
readonly get_terminal_output: import("ai").Tool<{
|
|
16
|
+
lines?: number | undefined;
|
|
17
|
+
}, {
|
|
18
|
+
error: string;
|
|
19
|
+
output?: undefined;
|
|
20
|
+
note?: undefined;
|
|
21
|
+
lines_returned?: undefined;
|
|
22
|
+
} | {
|
|
23
|
+
output: string;
|
|
24
|
+
note: string;
|
|
25
|
+
error?: undefined;
|
|
26
|
+
lines_returned?: undefined;
|
|
27
|
+
} | {
|
|
28
|
+
output: string;
|
|
29
|
+
lines_returned: number;
|
|
30
|
+
error?: undefined;
|
|
31
|
+
note?: undefined;
|
|
32
|
+
}>;
|
|
33
|
+
readonly open_preview: import("ai").Tool<{
|
|
34
|
+
url: string;
|
|
35
|
+
}, {
|
|
36
|
+
error: string;
|
|
37
|
+
url: string;
|
|
38
|
+
ok?: undefined;
|
|
39
|
+
} | {
|
|
40
|
+
url: string;
|
|
41
|
+
ok: boolean;
|
|
42
|
+
error?: undefined;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { checkShellCommand } from "../lib/security.js";
|
|
4
|
+
export function buildTerminalTools(ctx) {
|
|
5
|
+
return {
|
|
6
|
+
suggest_command: tool({
|
|
7
|
+
description: "Propose a single shell command. Renders a card in chat with an 'Insert' button — the command is NOT written to any terminal automatically; only the user's click inserts it at the prompt without executing. Use this when the answer IS a command.",
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
command: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe("The shell command. Single line, no trailing newline."),
|
|
12
|
+
explanation: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Optional one-line note shown beside the command."),
|
|
16
|
+
}),
|
|
17
|
+
execute: async ({ command, explanation }) => {
|
|
18
|
+
const safety = checkShellCommand(command);
|
|
19
|
+
if (!safety.ok)
|
|
20
|
+
return { error: safety.reason };
|
|
21
|
+
// Reject control bytes — the user inserts via click, but the rendered
|
|
22
|
+
// command must reflect exactly what will land at the prompt.
|
|
23
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control bytes is the entire point of this guard.
|
|
24
|
+
if (/[\n\r\x00\x1b\x07]/.test(command)) {
|
|
25
|
+
return {
|
|
26
|
+
error: "command must be a single line without control bytes",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { command, explanation };
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
get_terminal_output: tool({
|
|
33
|
+
description: "Return the tail of the active terminal's scrollback. Use this when the user references 'this error', 'the last command', or you need to interpret recent terminal output. Default is 80 lines; raise it only when you genuinely need more. Returns an empty string if there is no active terminal; refuses if the terminal is in Privacy mode.",
|
|
34
|
+
inputSchema: z.object({
|
|
35
|
+
lines: z
|
|
36
|
+
.number()
|
|
37
|
+
.int()
|
|
38
|
+
.min(1)
|
|
39
|
+
.max(2000)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Number of trailing lines to return. Default 80."),
|
|
42
|
+
}),
|
|
43
|
+
execute: async ({ lines }) => {
|
|
44
|
+
if (ctx.isActiveTerminalPrivate()) {
|
|
45
|
+
return {
|
|
46
|
+
error: "active terminal is in Privacy mode; its buffer is withheld. Ask the user to switch to a regular tab if they want you to see it.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const buffer = ctx.getTerminalContext();
|
|
50
|
+
if (!buffer)
|
|
51
|
+
return { output: "", note: "no active terminal" };
|
|
52
|
+
const n = lines ?? 80;
|
|
53
|
+
const parts = buffer.split("\n");
|
|
54
|
+
const sliced = parts.length <= n ? buffer : parts.slice(parts.length - n).join("\n");
|
|
55
|
+
const MAX = 24_000;
|
|
56
|
+
const capped = sliced.length > MAX
|
|
57
|
+
? `…[truncated]…\n${sliced.slice(sliced.length - MAX)}`
|
|
58
|
+
: sliced;
|
|
59
|
+
return { output: capped, lines_returned: Math.min(parts.length, n) };
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
open_preview: tool({
|
|
63
|
+
description: "Open a preview tab (in-app iframe) at the given URL — restricted to localhost/loopback addresses for the local dev server. Use this after starting a dev server (e.g. `pnpm dev`, `npm run dev`) to surface the rendered page next to the terminal. To preview external sites, the user should paste the URL into the preview address bar themselves.",
|
|
64
|
+
inputSchema: z.object({
|
|
65
|
+
url: z
|
|
66
|
+
.url()
|
|
67
|
+
.describe("Full URL to load (e.g. http://localhost:5173). Must include scheme. Only http/https on loopback hosts are accepted."),
|
|
68
|
+
}),
|
|
69
|
+
execute: async ({ url }) => {
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = new URL(url);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { error: "invalid URL", url };
|
|
76
|
+
}
|
|
77
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
78
|
+
return { error: "only http/https URLs are allowed", url };
|
|
79
|
+
}
|
|
80
|
+
const host = parsed.hostname;
|
|
81
|
+
const isLocal = host === "localhost" ||
|
|
82
|
+
host === "127.0.0.1" ||
|
|
83
|
+
host === "0.0.0.0" ||
|
|
84
|
+
host === "[::1]" ||
|
|
85
|
+
host === "::1" ||
|
|
86
|
+
host.endsWith(".localhost");
|
|
87
|
+
if (!isLocal) {
|
|
88
|
+
return {
|
|
89
|
+
error: "open_preview is restricted to localhost URLs. Ask the user to paste the external URL into the preview address bar instead.",
|
|
90
|
+
url,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const ok = ctx.openPreview(url);
|
|
94
|
+
if (!ok)
|
|
95
|
+
return { error: "preview surface unavailable", url };
|
|
96
|
+
return { url, ok: true };
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|