agent-sh 0.5.0 → 0.6.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/README.md +12 -43
- package/dist/agent/agent-loop.d.ts +1 -0
- package/dist/agent/agent-loop.js +119 -26
- package/dist/agent/subagent.js +3 -1
- package/dist/agent/system-prompt.d.ts +1 -1
- package/dist/agent/system-prompt.js +21 -16
- package/dist/agent/tools/bash.js +10 -1
- package/dist/agent/tools/display.d.ts +13 -0
- package/dist/agent/tools/display.js +70 -0
- package/dist/agent/tools/edit-file.js +60 -7
- package/dist/agent/tools/glob.js +39 -7
- package/dist/agent/tools/grep.js +111 -20
- package/dist/agent/tools/ls.js +31 -2
- package/dist/agent/tools/read-file.d.ts +9 -1
- package/dist/agent/tools/read-file.js +50 -4
- package/dist/agent/tools/user-shell.js +40 -13
- package/dist/agent/tools/write-file.js +9 -1
- package/dist/agent/types.d.ts +35 -1
- package/dist/core.d.ts +1 -3
- package/dist/core.js +7 -11
- package/dist/event-bus.d.ts +18 -3
- package/dist/extension-loader.d.ts +1 -1
- package/dist/extension-loader.js +1 -3
- package/dist/extensions/tui-renderer.js +341 -83
- package/dist/index.js +41 -36
- package/dist/input-handler.js +4 -2
- package/dist/settings.js +1 -1
- package/dist/shell.js +2 -2
- package/dist/utils/diff.js +10 -0
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +23 -1
- package/dist/utils/tool-display.d.ts +4 -0
- package/dist/utils/tool-display.js +22 -5
- package/examples/extensions/claude-code-bridge/index.ts +8 -12
- package/examples/extensions/pi-bridge/index.ts +10 -12
- package/examples/extensions/secret-guard.ts +100 -0
- package/package.json +1 -1
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { computeDiff } from "../../utils/diff.js";
|
|
4
|
+
/**
|
|
5
|
+
* Find the closest matching region in the file content to help diagnose
|
|
6
|
+
* why an exact match failed. Returns a hint string.
|
|
7
|
+
*/
|
|
8
|
+
function findClosestMatch(content, needle) {
|
|
9
|
+
const hints = [];
|
|
10
|
+
// Check if trimming whitespace would match
|
|
11
|
+
const trimmedNeedle = needle.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
|
|
12
|
+
const trimmedContent = content.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
|
|
13
|
+
if (trimmedContent.includes(trimmedNeedle)) {
|
|
14
|
+
hints.push(" Whitespace (indentation or trailing spaces) differs — check leading/trailing spaces on each line.");
|
|
15
|
+
return hints.join("");
|
|
16
|
+
}
|
|
17
|
+
// Check if the first line exists to narrow down the region
|
|
18
|
+
const needleLines = needle.split("\n");
|
|
19
|
+
const firstLine = needleLines[0].trim();
|
|
20
|
+
if (firstLine.length > 10) {
|
|
21
|
+
const contentLines = content.split("\n");
|
|
22
|
+
const matches = contentLines
|
|
23
|
+
.map((l, i) => ({ line: i + 1, text: l }))
|
|
24
|
+
.filter((l) => l.text.includes(firstLine));
|
|
25
|
+
if (matches.length > 0 && needleLines.length > 1) {
|
|
26
|
+
const loc = matches.map((m) => `line ${m.line}`).join(", ");
|
|
27
|
+
hints.push(` First line found at ${loc}, but subsequent lines differ. The file may have changed — use read_file to see current content around that region.`);
|
|
28
|
+
return hints.join("");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
hints.push(" Use read_file to verify the current file contents before retrying.");
|
|
32
|
+
return hints.join("");
|
|
33
|
+
}
|
|
4
34
|
export function createEditFileTool(getCwd) {
|
|
5
35
|
return {
|
|
6
36
|
name: "edit_file",
|
|
7
|
-
description: "Edit a file by replacing an exact text match with new text.
|
|
37
|
+
description: "Edit a file by replacing an exact text match with new text. " +
|
|
38
|
+
"The old_text must appear exactly once unless replace_all=true. " +
|
|
39
|
+
"Include enough context to make the match unique. " +
|
|
40
|
+
"Use replace_all for variable renames or bulk string replacements.",
|
|
8
41
|
input_schema: {
|
|
9
42
|
type: "object",
|
|
10
43
|
properties: {
|
|
@@ -20,6 +53,10 @@ export function createEditFileTool(getCwd) {
|
|
|
20
53
|
type: "string",
|
|
21
54
|
description: "Replacement text",
|
|
22
55
|
},
|
|
56
|
+
replace_all: {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
description: "Replace ALL occurrences instead of requiring a unique match. Useful for variable renames.",
|
|
59
|
+
},
|
|
23
60
|
},
|
|
24
61
|
required: ["path", "old_text", "new_text"],
|
|
25
62
|
},
|
|
@@ -28,12 +65,20 @@ export function createEditFileTool(getCwd) {
|
|
|
28
65
|
requiresPermission: true,
|
|
29
66
|
getDisplayInfo: (args) => ({
|
|
30
67
|
kind: "write",
|
|
68
|
+
icon: "✎",
|
|
31
69
|
locations: [{ path: args.path }],
|
|
32
70
|
}),
|
|
71
|
+
formatResult: (_args, result) => {
|
|
72
|
+
if (result.isError)
|
|
73
|
+
return {};
|
|
74
|
+
const m = result.content.match(/\((\+\d+(?:\s-\d+)?)\)/);
|
|
75
|
+
return m ? { summary: m[1] } : {};
|
|
76
|
+
},
|
|
33
77
|
async execute(args, onChunk) {
|
|
34
78
|
const filePath = args.path;
|
|
35
79
|
const oldText = args.old_text;
|
|
36
80
|
const newText = args.new_text;
|
|
81
|
+
const replaceAll = args.replace_all ?? false;
|
|
37
82
|
const absPath = path.resolve(getCwd(), filePath);
|
|
38
83
|
try {
|
|
39
84
|
const content = await fs.readFile(absPath, "utf-8");
|
|
@@ -42,23 +87,31 @@ export function createEditFileTool(getCwd) {
|
|
|
42
87
|
const normalizedOld = oldText.replace(/\r\n/g, "\n");
|
|
43
88
|
const occurrences = normalized.split(normalizedOld).length - 1;
|
|
44
89
|
if (occurrences === 0) {
|
|
90
|
+
// Try to find the closest match to help the agent self-correct
|
|
91
|
+
const hint = findClosestMatch(normalized, normalizedOld);
|
|
45
92
|
return {
|
|
46
|
-
content: `Error: old_text not found in ${filePath}`,
|
|
93
|
+
content: `Error: old_text not found in ${filePath}.${hint}`,
|
|
47
94
|
exitCode: 1,
|
|
48
95
|
isError: true,
|
|
49
96
|
};
|
|
50
97
|
}
|
|
51
|
-
if (occurrences > 1) {
|
|
98
|
+
if (occurrences > 1 && !replaceAll) {
|
|
52
99
|
return {
|
|
53
|
-
content: `Error: old_text found ${occurrences} times, must be unique. Add more surrounding context.`,
|
|
100
|
+
content: `Error: old_text found ${occurrences} times, must be unique. Add more surrounding context or use replace_all=true.`,
|
|
54
101
|
exitCode: 1,
|
|
55
102
|
isError: true,
|
|
56
103
|
};
|
|
57
104
|
}
|
|
58
105
|
const normalizedNew = newText.replace(/\r\n/g, "\n");
|
|
59
|
-
const newContent =
|
|
60
|
-
|
|
61
|
-
|
|
106
|
+
const newContent = replaceAll
|
|
107
|
+
? normalized.split(normalizedOld).join(normalizedNew)
|
|
108
|
+
: normalized.replace(normalizedOld, normalizedNew);
|
|
109
|
+
// Restore original line endings — only convert if the file was
|
|
110
|
+
// predominantly CRLF (>50% of line endings), to avoid corrupting
|
|
111
|
+
// mixed-ending files.
|
|
112
|
+
const crlfCount = (content.match(/\r\n/g) || []).length;
|
|
113
|
+
const lfCount = (content.match(/(?<!\r)\n/g) || []).length;
|
|
114
|
+
const useCRLF = crlfCount > 0 && crlfCount >= lfCount;
|
|
62
115
|
const finalContent = useCRLF
|
|
63
116
|
? newContent.replace(/\n/g, "\r\n")
|
|
64
117
|
: newContent;
|
package/dist/agent/tools/glob.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import { executeCommand } from "../../executor.js";
|
|
2
4
|
export function createGlobTool(getCwd) {
|
|
3
5
|
return {
|
|
4
6
|
name: "glob",
|
|
5
|
-
description: "Find files
|
|
7
|
+
description: "Find files by name pattern. Returns paths sorted by modification time (newest first). " +
|
|
8
|
+
"ALWAYS use this instead of find/ls via bash. " +
|
|
9
|
+
"Use glob to locate files, then read_file or grep to inspect contents.",
|
|
6
10
|
input_schema: {
|
|
7
11
|
type: "object",
|
|
8
12
|
properties: {
|
|
@@ -18,8 +22,15 @@ export function createGlobTool(getCwd) {
|
|
|
18
22
|
required: ["pattern"],
|
|
19
23
|
},
|
|
20
24
|
showOutput: false,
|
|
25
|
+
formatResult: (_args, result) => {
|
|
26
|
+
if (result.isError || result.content === "No files matched.")
|
|
27
|
+
return { summary: "0 files" };
|
|
28
|
+
const lines = result.content.split("\n").filter(l => l && !l.startsWith("["));
|
|
29
|
+
return { summary: `${lines.length} files` };
|
|
30
|
+
},
|
|
21
31
|
getDisplayInfo: (args) => ({
|
|
22
32
|
kind: "search",
|
|
33
|
+
icon: "⌕",
|
|
23
34
|
locations: args.path
|
|
24
35
|
? [{ path: args.path }]
|
|
25
36
|
: [],
|
|
@@ -27,9 +38,15 @@ export function createGlobTool(getCwd) {
|
|
|
27
38
|
async execute(args) {
|
|
28
39
|
const pattern = args.pattern;
|
|
29
40
|
const searchPath = args.path ?? ".";
|
|
30
|
-
// Use
|
|
41
|
+
// Use ripgrep for correct glob matching + .gitignore awareness
|
|
42
|
+
const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
|
43
|
+
const parts = [
|
|
44
|
+
"rg", "--files",
|
|
45
|
+
"--glob", shellEsc(pattern),
|
|
46
|
+
shellEsc(searchPath),
|
|
47
|
+
];
|
|
31
48
|
const { session, done } = executeCommand({
|
|
32
|
-
command:
|
|
49
|
+
command: parts.join(" "),
|
|
33
50
|
cwd: getCwd(),
|
|
34
51
|
timeout: 10_000,
|
|
35
52
|
});
|
|
@@ -41,12 +58,27 @@ export function createGlobTool(getCwd) {
|
|
|
41
58
|
isError: false,
|
|
42
59
|
};
|
|
43
60
|
}
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
61
|
+
const cwd = getCwd();
|
|
62
|
+
const files = session.output.trim().split("\n");
|
|
63
|
+
// Sort by modification time (newest first)
|
|
64
|
+
const withMtime = await Promise.all(files.map(async (f) => {
|
|
65
|
+
try {
|
|
66
|
+
const abs = path.resolve(cwd, f);
|
|
67
|
+
const stat = await fs.stat(abs);
|
|
68
|
+
return { file: f, mtime: stat.mtimeMs };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { file: f, mtime: 0 };
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
75
|
+
const sorted = withMtime.slice(0, 200).map((e) => e.file);
|
|
76
|
+
const truncated = files.length > 200;
|
|
77
|
+
const suffix = truncated
|
|
78
|
+
? `\n[Results capped at 200 files, ${files.length - 200} more matched]`
|
|
47
79
|
: "";
|
|
48
80
|
return {
|
|
49
|
-
content:
|
|
81
|
+
content: sorted.join("\n") + suffix,
|
|
50
82
|
exitCode: 0,
|
|
51
83
|
isError: false,
|
|
52
84
|
};
|
package/dist/agent/tools/grep.js
CHANGED
|
@@ -2,7 +2,12 @@ import { executeCommand } from "../../executor.js";
|
|
|
2
2
|
export function createGrepTool(getCwd) {
|
|
3
3
|
return {
|
|
4
4
|
name: "grep",
|
|
5
|
-
description: "Search file contents using ripgrep
|
|
5
|
+
description: "Search file contents using ripgrep. ALWAYS use this instead of running grep/rg via bash. " +
|
|
6
|
+
"Supports three output modes: " +
|
|
7
|
+
"'files_with_matches' (default, returns file paths only — use this to find which files contain a pattern), " +
|
|
8
|
+
"'content' (matching lines with optional context_before/context_after), and " +
|
|
9
|
+
"'count' (match counts per file). " +
|
|
10
|
+
"Use head_limit and offset for pagination.",
|
|
6
11
|
input_schema: {
|
|
7
12
|
type: "object",
|
|
8
13
|
properties: {
|
|
@@ -18,12 +23,55 @@ export function createGrepTool(getCwd) {
|
|
|
18
23
|
type: "string",
|
|
19
24
|
description: "Glob pattern for files to include (e.g., '*.ts')",
|
|
20
25
|
},
|
|
26
|
+
output_mode: {
|
|
27
|
+
type: "string",
|
|
28
|
+
enum: ["files_with_matches", "content", "count"],
|
|
29
|
+
description: "Output mode: 'files_with_matches' (default, file paths only), " +
|
|
30
|
+
"'content' (matching lines), 'count' (match counts per file)",
|
|
31
|
+
},
|
|
32
|
+
case_insensitive: {
|
|
33
|
+
type: "boolean",
|
|
34
|
+
description: "Case insensitive search (default: false)",
|
|
35
|
+
},
|
|
36
|
+
context_before: {
|
|
37
|
+
type: "number",
|
|
38
|
+
description: "Lines to show before each match (content mode only)",
|
|
39
|
+
},
|
|
40
|
+
context_after: {
|
|
41
|
+
type: "number",
|
|
42
|
+
description: "Lines to show after each match (content mode only)",
|
|
43
|
+
},
|
|
44
|
+
head_limit: {
|
|
45
|
+
type: "number",
|
|
46
|
+
description: "Max lines/entries to return (default: 200 for files_with_matches, 150 for content/count). Pass 0 for unlimited.",
|
|
47
|
+
},
|
|
48
|
+
offset: {
|
|
49
|
+
type: "number",
|
|
50
|
+
description: "Skip first N lines/entries before applying head_limit. Use with head_limit for pagination.",
|
|
51
|
+
},
|
|
21
52
|
},
|
|
22
53
|
required: ["pattern"],
|
|
23
54
|
},
|
|
24
55
|
showOutput: false,
|
|
56
|
+
formatResult: (args, result) => {
|
|
57
|
+
if (result.isError || result.content === "No matches found.")
|
|
58
|
+
return { summary: "0 matches" };
|
|
59
|
+
const lines = result.content.split("\n").filter(Boolean);
|
|
60
|
+
// Strip pagination info line from count
|
|
61
|
+
const resultLines = lines.filter(l => !l.startsWith("[Showing "));
|
|
62
|
+
const mode = args.output_mode ?? "files_with_matches";
|
|
63
|
+
if (mode === "files_with_matches") {
|
|
64
|
+
return { summary: `${resultLines.length} files` };
|
|
65
|
+
}
|
|
66
|
+
if (mode === "count") {
|
|
67
|
+
const total = resultLines.reduce((sum, l) => sum + (parseInt(l.split(":").pop() ?? "0", 10) || 0), 0);
|
|
68
|
+
return { summary: `${total} matches` };
|
|
69
|
+
}
|
|
70
|
+
return { summary: `${resultLines.length} lines` };
|
|
71
|
+
},
|
|
25
72
|
getDisplayInfo: (args) => ({
|
|
26
73
|
kind: "search",
|
|
74
|
+
icon: "⌕",
|
|
27
75
|
locations: args.path
|
|
28
76
|
? [{ path: args.path }]
|
|
29
77
|
: [],
|
|
@@ -32,17 +80,42 @@ export function createGrepTool(getCwd) {
|
|
|
32
80
|
const pattern = args.pattern;
|
|
33
81
|
const searchPath = args.path ?? ".";
|
|
34
82
|
const include = args.include;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
83
|
+
const mode = args.output_mode ?? "files_with_matches";
|
|
84
|
+
const caseInsensitive = args.case_insensitive;
|
|
85
|
+
const contextBefore = args.context_before;
|
|
86
|
+
const contextAfter = args.context_after;
|
|
87
|
+
const headLimit = args.head_limit;
|
|
88
|
+
const offset = args.offset ?? 0;
|
|
89
|
+
const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
|
90
|
+
const parts = ["rg", "--color=never"];
|
|
91
|
+
// Mode-specific flags
|
|
92
|
+
if (mode === "files_with_matches") {
|
|
93
|
+
parts.push("--files-with-matches");
|
|
94
|
+
}
|
|
95
|
+
else if (mode === "count") {
|
|
96
|
+
parts.push("--count");
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// content mode
|
|
100
|
+
parts.push("--line-number", "--no-heading");
|
|
101
|
+
if (contextBefore != null && contextBefore > 0) {
|
|
102
|
+
parts.push(`-B${contextBefore}`);
|
|
103
|
+
}
|
|
104
|
+
if (contextAfter != null && contextAfter > 0) {
|
|
105
|
+
parts.push(`-A${contextAfter}`);
|
|
106
|
+
}
|
|
107
|
+
// If neither -A nor -B specified, use --max-count to limit per-file
|
|
108
|
+
if (contextBefore == null && contextAfter == null) {
|
|
109
|
+
parts.push("--max-count=50");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (caseInsensitive) {
|
|
113
|
+
parts.push("-i");
|
|
114
|
+
}
|
|
42
115
|
if (include) {
|
|
43
|
-
parts.push("--glob",
|
|
116
|
+
parts.push("--glob", shellEsc(include));
|
|
44
117
|
}
|
|
45
|
-
parts.push(
|
|
118
|
+
parts.push("-e", shellEsc(pattern), shellEsc(searchPath));
|
|
46
119
|
const { session, done } = executeCommand({
|
|
47
120
|
command: parts.join(" "),
|
|
48
121
|
cwd: getCwd(),
|
|
@@ -57,18 +130,36 @@ export function createGrepTool(getCwd) {
|
|
|
57
130
|
isError: false,
|
|
58
131
|
};
|
|
59
132
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
133
|
+
let output = session.output;
|
|
134
|
+
// Cap individual line lengths to 500 chars to prevent minified/base64 flood
|
|
135
|
+
if (mode === "content") {
|
|
136
|
+
const MAX_LINE_LEN = 500;
|
|
137
|
+
output = output
|
|
138
|
+
.split("\n")
|
|
139
|
+
.map((line) => line.length > MAX_LINE_LEN
|
|
140
|
+
? line.slice(0, MAX_LINE_LEN) + "… [truncated]"
|
|
141
|
+
: line)
|
|
142
|
+
.join("\n");
|
|
143
|
+
}
|
|
144
|
+
// Apply pagination (offset + head_limit)
|
|
145
|
+
const defaultLimit = mode === "files_with_matches" ? 200 : 150;
|
|
146
|
+
const limit = headLimit === 0 ? Infinity : (headLimit ?? defaultLimit);
|
|
147
|
+
const lines = output.split("\n");
|
|
148
|
+
const total = lines.length;
|
|
149
|
+
// Apply offset then limit
|
|
150
|
+
const sliced = lines.slice(offset, offset + limit);
|
|
151
|
+
const paginated = sliced.join("\n");
|
|
152
|
+
const parts2 = [];
|
|
153
|
+
if (paginated)
|
|
154
|
+
parts2.push(paginated);
|
|
155
|
+
// Show pagination info when offset is used or results were truncated
|
|
156
|
+
if (offset > 0 || offset + limit < total) {
|
|
157
|
+
const shown = sliced.length;
|
|
158
|
+
const remaining = Math.max(0, total - offset - shown);
|
|
159
|
+
parts2.push(`\n[Showing ${shown} results (offset=${offset}, limit=${limit === Infinity ? "unlimited" : limit})${remaining > 0 ? `, ${remaining} more available` : ""}]`);
|
|
69
160
|
}
|
|
70
161
|
return {
|
|
71
|
-
content:
|
|
162
|
+
content: parts2.join("\n") || "No matches found.",
|
|
72
163
|
exitCode: 0,
|
|
73
164
|
isError: false,
|
|
74
165
|
};
|
package/dist/agent/tools/ls.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
function formatSize(bytes) {
|
|
4
|
+
if (bytes < 1024)
|
|
5
|
+
return `${bytes}B`;
|
|
6
|
+
if (bytes < 1024 * 1024)
|
|
7
|
+
return `${(bytes / 1024).toFixed(1)}K`;
|
|
8
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
9
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
10
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
|
11
|
+
}
|
|
3
12
|
export function createLsTool(getCwd) {
|
|
4
13
|
return {
|
|
5
14
|
name: "ls",
|
|
6
|
-
description: "List files and directories
|
|
15
|
+
description: "List files and directories with timestamps and sizes. " +
|
|
16
|
+
"Use for exploring a single directory. Use glob for recursive file search by pattern.",
|
|
7
17
|
input_schema: {
|
|
8
18
|
type: "object",
|
|
9
19
|
properties: {
|
|
@@ -16,10 +26,17 @@ export function createLsTool(getCwd) {
|
|
|
16
26
|
showOutput: false,
|
|
17
27
|
getDisplayInfo: (args) => ({
|
|
18
28
|
kind: "read",
|
|
29
|
+
icon: "◆",
|
|
19
30
|
locations: args.path
|
|
20
31
|
? [{ path: args.path }]
|
|
21
32
|
: [],
|
|
22
33
|
}),
|
|
34
|
+
formatResult: (_args, result) => {
|
|
35
|
+
if (result.isError || result.content === "(empty directory)")
|
|
36
|
+
return { summary: "0 entries" };
|
|
37
|
+
const lines = result.content.split("\n").filter(Boolean);
|
|
38
|
+
return { summary: `${lines.length} entries` };
|
|
39
|
+
},
|
|
23
40
|
async execute(args) {
|
|
24
41
|
const dirPath = args.path ?? ".";
|
|
25
42
|
const absPath = path.resolve(getCwd(), dirPath);
|
|
@@ -27,7 +44,19 @@ export function createLsTool(getCwd) {
|
|
|
27
44
|
const entries = await fs.readdir(absPath, {
|
|
28
45
|
withFileTypes: true,
|
|
29
46
|
});
|
|
30
|
-
const lines =
|
|
47
|
+
const lines = [];
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
const fullPath = path.join(absPath, e.name);
|
|
50
|
+
try {
|
|
51
|
+
const stat = await fs.stat(fullPath);
|
|
52
|
+
const size = e.isDirectory() ? "-" : formatSize(stat.size);
|
|
53
|
+
const mtime = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
54
|
+
lines.push(`${mtime} ${size.padStart(8)} ${e.isDirectory() ? e.name + "/" : e.name}`);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
lines.push(`${"?".padStart(16)} ${"?".padStart(8)} ${e.isDirectory() ? e.name + "/" : e.name}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
31
60
|
return {
|
|
32
61
|
content: lines.join("\n") || "(empty directory)",
|
|
33
62
|
exitCode: 0,
|
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
import type { ToolDefinition } from "../types.js";
|
|
2
|
-
|
|
2
|
+
/** Tracks the last-read state of a file for deduplication. */
|
|
3
|
+
export interface FileReadState {
|
|
4
|
+
mtimeMs: number;
|
|
5
|
+
offset: number;
|
|
6
|
+
limit: number | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Shared cache — keyed by absolute path. */
|
|
9
|
+
export type FileReadCache = Map<string, FileReadState>;
|
|
10
|
+
export declare function createReadFileTool(getCwd: () => string, cache?: FileReadCache): ToolDefinition;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
export function createReadFileTool(getCwd) {
|
|
3
|
+
export function createReadFileTool(getCwd, cache) {
|
|
4
4
|
return {
|
|
5
5
|
name: "read_file",
|
|
6
|
-
description: "Read a file's contents with line numbers.
|
|
6
|
+
description: "Read a file's contents with line numbers. Use offset and limit for large files. " +
|
|
7
|
+
"Always read a file before editing it. " +
|
|
8
|
+
"If the file hasn't changed since last read, returns a stub to save context.",
|
|
7
9
|
input_schema: {
|
|
8
10
|
type: "object",
|
|
9
11
|
properties: {
|
|
@@ -25,16 +27,52 @@ export function createReadFileTool(getCwd) {
|
|
|
25
27
|
showOutput: false,
|
|
26
28
|
getDisplayInfo: (args) => ({
|
|
27
29
|
kind: "read",
|
|
30
|
+
icon: "◆",
|
|
28
31
|
locations: [{ path: args.path }],
|
|
29
32
|
}),
|
|
33
|
+
formatResult: (_args, result) => {
|
|
34
|
+
if (result.isError)
|
|
35
|
+
return {};
|
|
36
|
+
if (result.content.startsWith("File unchanged"))
|
|
37
|
+
return { summary: "cached" };
|
|
38
|
+
const lines = result.content.split("\n").filter(l => !l.startsWith("["));
|
|
39
|
+
return { summary: `${lines.length} lines` };
|
|
40
|
+
},
|
|
30
41
|
async execute(args) {
|
|
31
42
|
const filePath = args.path;
|
|
32
43
|
const absPath = path.resolve(getCwd(), filePath);
|
|
44
|
+
const reqOffset = args.offset ?? 1;
|
|
45
|
+
const reqLimit = args.limit;
|
|
33
46
|
try {
|
|
47
|
+
const stat = await fs.stat(absPath);
|
|
48
|
+
// Deduplication: if the file hasn't changed and same range, return stub
|
|
49
|
+
if (cache) {
|
|
50
|
+
const prev = cache.get(absPath);
|
|
51
|
+
if (prev &&
|
|
52
|
+
prev.mtimeMs === stat.mtimeMs &&
|
|
53
|
+
prev.offset === reqOffset &&
|
|
54
|
+
prev.limit === reqLimit) {
|
|
55
|
+
return {
|
|
56
|
+
content: "File unchanged since last read. The content from the earlier read_file result in this conversation is still current — refer to that instead of re-reading.",
|
|
57
|
+
exitCode: 0,
|
|
58
|
+
isError: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Check file size before reading to avoid OOM on huge files
|
|
63
|
+
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
|
64
|
+
if (stat.size > MAX_FILE_SIZE && !args.offset && !args.limit) {
|
|
65
|
+
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
66
|
+
return {
|
|
67
|
+
content: `File is ${sizeMB}MB (${stat.size} bytes) — too large to read in full. Use offset and limit to read specific sections, e.g. offset=1 limit=200.`,
|
|
68
|
+
exitCode: 1,
|
|
69
|
+
isError: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
34
72
|
const content = await fs.readFile(absPath, "utf-8");
|
|
35
73
|
const lines = content.split("\n");
|
|
36
|
-
const start =
|
|
37
|
-
const end =
|
|
74
|
+
const start = reqOffset - 1; // 1-indexed → 0-indexed
|
|
75
|
+
const end = reqLimit ? start + reqLimit : lines.length;
|
|
38
76
|
const slice = lines.slice(start, end);
|
|
39
77
|
// Add line numbers (1-indexed)
|
|
40
78
|
const numbered = slice
|
|
@@ -44,6 +82,14 @@ export function createReadFileTool(getCwd) {
|
|
|
44
82
|
const suffix = truncated
|
|
45
83
|
? `\n[${lines.length - end} more lines, use offset=${end + 1} to continue]`
|
|
46
84
|
: "";
|
|
85
|
+
// Update cache on successful read
|
|
86
|
+
if (cache) {
|
|
87
|
+
cache.set(absPath, {
|
|
88
|
+
mtimeMs: stat.mtimeMs,
|
|
89
|
+
offset: reqOffset,
|
|
90
|
+
limit: reqLimit,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
47
93
|
return { content: numbered + suffix, exitCode: 0, isError: false };
|
|
48
94
|
}
|
|
49
95
|
catch (err) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
export function createUserShellTool(opts) {
|
|
9
9
|
return {
|
|
10
10
|
name: "user_shell",
|
|
11
|
-
description: "Run a command in the user's live shell (
|
|
11
|
+
description: "Run a command with lasting effects in the user's live shell (cd, export, install packages, start servers, apply changes). Output is shown directly to the user but NOT returned to you by default — set return_output=true if you need to inspect the result.",
|
|
12
12
|
input_schema: {
|
|
13
13
|
type: "object",
|
|
14
14
|
properties: {
|
|
@@ -16,6 +16,10 @@ export function createUserShellTool(opts) {
|
|
|
16
16
|
type: "string",
|
|
17
17
|
description: "Command to execute in user's shell",
|
|
18
18
|
},
|
|
19
|
+
timeout: {
|
|
20
|
+
type: "number",
|
|
21
|
+
description: "Timeout in seconds (default: 30)",
|
|
22
|
+
},
|
|
19
23
|
return_output: {
|
|
20
24
|
type: "boolean",
|
|
21
25
|
default: false,
|
|
@@ -28,29 +32,52 @@ export function createUserShellTool(opts) {
|
|
|
28
32
|
modifiesFiles: true,
|
|
29
33
|
getDisplayInfo: () => ({
|
|
30
34
|
kind: "execute",
|
|
35
|
+
icon: "▷",
|
|
31
36
|
locations: [],
|
|
32
37
|
}),
|
|
33
38
|
async execute(args) {
|
|
34
39
|
const command = args.command;
|
|
40
|
+
const timeoutSec = args.timeout ?? 30;
|
|
35
41
|
const returnOutput = args.return_output ?? false;
|
|
36
|
-
// Execute via the shell-exec extension's async pipe
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
// Execute via the shell-exec extension's async pipe with timeout
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
const execPromise = opts.bus.emitPipeAsync("shell:exec-request", {
|
|
46
|
+
command,
|
|
47
|
+
output: "",
|
|
48
|
+
cwd: opts.getCwd(),
|
|
49
|
+
exitCode: null,
|
|
50
|
+
done: false,
|
|
51
|
+
});
|
|
52
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutSec * 1000));
|
|
53
|
+
result = await Promise.race([execPromise, timeoutPromise]);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
57
|
+
if (msg === "timeout") {
|
|
58
|
+
return {
|
|
59
|
+
content: `Command timed out after ${timeoutSec}s.`,
|
|
60
|
+
exitCode: -1,
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { content: `Error: ${msg}`, exitCode: -1, isError: true };
|
|
65
|
+
}
|
|
66
|
+
const exitCode = result.exitCode ?? 0;
|
|
67
|
+
const isError = exitCode !== 0 && exitCode !== null;
|
|
43
68
|
if (returnOutput) {
|
|
44
69
|
return {
|
|
45
70
|
content: result.output || "(no output)",
|
|
46
|
-
exitCode
|
|
47
|
-
isError
|
|
71
|
+
exitCode,
|
|
72
|
+
isError,
|
|
48
73
|
};
|
|
49
74
|
}
|
|
50
75
|
return {
|
|
51
|
-
content:
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
content: isError
|
|
77
|
+
? `Command failed with exit code ${exitCode}.`
|
|
78
|
+
: "Command executed.",
|
|
79
|
+
exitCode,
|
|
80
|
+
isError,
|
|
54
81
|
};
|
|
55
82
|
},
|
|
56
83
|
};
|
|
@@ -4,7 +4,8 @@ import { computeDiff } from "../../utils/diff.js";
|
|
|
4
4
|
export function createWriteFileTool(getCwd) {
|
|
5
5
|
return {
|
|
6
6
|
name: "write_file",
|
|
7
|
-
description: "Create
|
|
7
|
+
description: "Create a new file or completely overwrite an existing one. Creates parent directories if needed. " +
|
|
8
|
+
"ALWAYS prefer edit_file for modifying existing files — only use write_file for new files or complete rewrites.",
|
|
8
9
|
input_schema: {
|
|
9
10
|
type: "object",
|
|
10
11
|
properties: {
|
|
@@ -24,8 +25,15 @@ export function createWriteFileTool(getCwd) {
|
|
|
24
25
|
requiresPermission: true,
|
|
25
26
|
getDisplayInfo: (args) => ({
|
|
26
27
|
kind: "write",
|
|
28
|
+
icon: "✎",
|
|
27
29
|
locations: [{ path: args.path }],
|
|
28
30
|
}),
|
|
31
|
+
formatResult: (_args, result) => {
|
|
32
|
+
if (result.isError)
|
|
33
|
+
return {};
|
|
34
|
+
const m = result.content.match(/\((\+\d+(?:\s-\d+)?)\)/);
|
|
35
|
+
return m ? { summary: m[1] } : {};
|
|
36
|
+
},
|
|
29
37
|
async execute(args, onChunk) {
|
|
30
38
|
const filePath = args.path;
|
|
31
39
|
const content = args.content;
|