@tomsun28/pizza 0.0.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/.claude/settings.local.json +14 -0
- package/dist/main.js +91 -0
- package/package.json +33 -0
- package/src/agent.ts +44 -0
- package/src/cli-convert.ts +104 -0
- package/src/cli-stream.ts +291 -0
- package/src/main.ts +103 -0
- package/src/system-prompt.ts +94 -0
- package/src/tools/bash.ts +124 -0
- package/src/tools/edit.ts +50 -0
- package/src/tools/grep.ts +124 -0
- package/src/tools/index.ts +25 -0
- package/src/tools/ls.ts +52 -0
- package/src/tools/read.ts +69 -0
- package/src/tools/write.ts +31 -0
- package/src/ui/render.ts +131 -0
- package/system-prompt.txt +55 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export function buildSystemPrompt(cwd: string): string {
|
|
2
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
3
|
+
|
|
4
|
+
return [
|
|
5
|
+
`You are pizza, a coding assistant running in the user's terminal. CLI is all you need.`,
|
|
6
|
+
``,
|
|
7
|
+
`When you need to perform an action, wrap a CLI command in [CLI] ... [/CLI] tags. You can use multiple tags in one response. Results are returned in [RESULT]...[/RESULT] tags.`,
|
|
8
|
+
``,
|
|
9
|
+
`## Available Commands`,
|
|
10
|
+
``,
|
|
11
|
+
`[CLI] ls <path>[/CLI]`,
|
|
12
|
+
`[CLI] read <path>[/CLI]`,
|
|
13
|
+
`[CLI] read <path> --offset <n> --limit <n>[/CLI]`,
|
|
14
|
+
`[CLI] write --path <path> --content <content>[/CLI]`,
|
|
15
|
+
`[CLI] edit --path <path> --old <oldText> --new <newText>[/CLI]`,
|
|
16
|
+
`[CLI] grep <pattern> --path <dir> --include <glob>[/CLI]`,
|
|
17
|
+
`[CLI] bash <any shell command>[/CLI]`,
|
|
18
|
+
``,
|
|
19
|
+
`## Examples`,
|
|
20
|
+
``,
|
|
21
|
+
`User: What files are in this project?`,
|
|
22
|
+
``,
|
|
23
|
+
`Assistant: Let me check.`,
|
|
24
|
+
``,
|
|
25
|
+
`[CLI] ls .[/CLI]`,
|
|
26
|
+
``,
|
|
27
|
+
`[RESULT]`,
|
|
28
|
+
`src/`,
|
|
29
|
+
`package.json`,
|
|
30
|
+
`tsconfig.json`,
|
|
31
|
+
`[/RESULT]`,
|
|
32
|
+
``,
|
|
33
|
+
`The project has a src/ directory, package.json, and tsconfig.json.`,
|
|
34
|
+
``,
|
|
35
|
+
`---`,
|
|
36
|
+
``,
|
|
37
|
+
`User: Read package.json and list src/`,
|
|
38
|
+
``,
|
|
39
|
+
`Assistant: I'll do both at once.`,
|
|
40
|
+
``,
|
|
41
|
+
`[CLI] read package.json[/CLI]`,
|
|
42
|
+
`[CLI] ls src[/CLI]`,
|
|
43
|
+
``,
|
|
44
|
+
`[RESULT id=1]`,
|
|
45
|
+
`{"name":"my-app","version":"1.0.0"}`,
|
|
46
|
+
`[/RESULT]`,
|
|
47
|
+
``,
|
|
48
|
+
`[RESULT id=2]`,
|
|
49
|
+
`index.ts`,
|
|
50
|
+
`utils.ts`,
|
|
51
|
+
`[/RESULT]`,
|
|
52
|
+
``,
|
|
53
|
+
`The project is called "my-app". src/ contains index.ts and utils.ts.`,
|
|
54
|
+
``,
|
|
55
|
+
`---`,
|
|
56
|
+
``,
|
|
57
|
+
`User: Add a hello function to src/utils.ts`,
|
|
58
|
+
``,
|
|
59
|
+
`Assistant: Let me read the file first.`,
|
|
60
|
+
``,
|
|
61
|
+
`[CLI] read src/utils.ts[/CLI]`,
|
|
62
|
+
``,
|
|
63
|
+
`[RESULT]`,
|
|
64
|
+
`export function add(a: number, b: number): number {`,
|
|
65
|
+
` return a + b;`,
|
|
66
|
+
`}`,
|
|
67
|
+
`[/RESULT]`,
|
|
68
|
+
``,
|
|
69
|
+
`[CLI] edit --path src/utils.ts --old "return a + b;
|
|
70
|
+
}" --new "return a + b;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hello(name: string): string {
|
|
74
|
+
return "Hello, " + name;
|
|
75
|
+
}"[/CLI]`,
|
|
76
|
+
``,
|
|
77
|
+
`[RESULT]`,
|
|
78
|
+
`File edited successfully.`,
|
|
79
|
+
`[/RESULT]`,
|
|
80
|
+
``,
|
|
81
|
+
`Done. Added a \`hello\` function to src/utils.ts.`,
|
|
82
|
+
``,
|
|
83
|
+
`---`,
|
|
84
|
+
``,
|
|
85
|
+
`User: Run git status and install deps`,
|
|
86
|
+
``,
|
|
87
|
+
`Assistant:`,
|
|
88
|
+
``,
|
|
89
|
+
`[CLI] bash git status[/CLI]`,
|
|
90
|
+
`[CLI] bash npm install[/CLI]`,
|
|
91
|
+
`Current directory: ${cwd}`,
|
|
92
|
+
`Date: ${date}`,
|
|
93
|
+
].join("\n");
|
|
94
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
6
|
+
const MAX_OUTPUT_LINES = 2000;
|
|
7
|
+
|
|
8
|
+
const schema = Type.Object({
|
|
9
|
+
command: Type.String({ description: "Bash command to execute" }),
|
|
10
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional)" })),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function createBashTool(cwd: string): AgentTool<typeof schema> {
|
|
14
|
+
return {
|
|
15
|
+
name: "bash",
|
|
16
|
+
label: "bash",
|
|
17
|
+
description: `Execute a bash command. Returns stdout and stderr. Output truncated to ${MAX_OUTPUT_LINES} lines or ${MAX_OUTPUT_BYTES / 1024}KB.`,
|
|
18
|
+
parameters: schema,
|
|
19
|
+
async execute(_toolCallId, { command, timeout }, signal, onUpdate) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
22
|
+
const child = spawn(shell, ["-c", command], {
|
|
23
|
+
cwd,
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
env: { ...process.env },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const chunks: Buffer[] = [];
|
|
29
|
+
let totalBytes = 0;
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
32
|
+
|
|
33
|
+
if (timeout !== undefined && timeout > 0) {
|
|
34
|
+
timeoutHandle = setTimeout(() => {
|
|
35
|
+
timedOut = true;
|
|
36
|
+
child.kill("SIGTERM");
|
|
37
|
+
setTimeout(() => child.kill("SIGKILL"), 2000);
|
|
38
|
+
}, timeout * 1000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const handleData = (data: Buffer) => {
|
|
42
|
+
chunks.push(data);
|
|
43
|
+
totalBytes += data.length;
|
|
44
|
+
|
|
45
|
+
if (onUpdate) {
|
|
46
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
47
|
+
const truncated = truncateOutput(text);
|
|
48
|
+
onUpdate({
|
|
49
|
+
content: [{ type: "text", text: truncated }],
|
|
50
|
+
details: {},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
child.stdout?.on("data", handleData);
|
|
56
|
+
child.stderr?.on("data", handleData);
|
|
57
|
+
|
|
58
|
+
const onAbort = () => {
|
|
59
|
+
child.kill("SIGTERM");
|
|
60
|
+
setTimeout(() => child.kill("SIGKILL"), 2000);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (signal) {
|
|
64
|
+
if (signal.aborted) {
|
|
65
|
+
onAbort();
|
|
66
|
+
} else {
|
|
67
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
child.on("close", (code) => {
|
|
72
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
73
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
74
|
+
|
|
75
|
+
const output = Buffer.concat(chunks).toString("utf-8");
|
|
76
|
+
const truncated = truncateOutput(output);
|
|
77
|
+
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
reject(new Error(truncated ? `${truncated}\n\nCommand aborted` : "Command aborted"));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (timedOut) {
|
|
84
|
+
reject(new Error(truncated ? `${truncated}\n\nCommand timed out after ${timeout}s` : `Command timed out after ${timeout}s`));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (code !== 0 && code !== null) {
|
|
89
|
+
reject(new Error(`${truncated || "(no output)"}\n\nExit code ${code}`));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
resolve({
|
|
94
|
+
content: [{ type: "text", text: truncated || "(no output)" }],
|
|
95
|
+
details: {},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
child.on("error", (err) => {
|
|
100
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
101
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
102
|
+
reject(err);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function truncateOutput(text: string): string {
|
|
110
|
+
const lines = text.split("\n");
|
|
111
|
+
|
|
112
|
+
if (lines.length > MAX_OUTPUT_LINES) {
|
|
113
|
+
const kept = lines.slice(lines.length - MAX_OUTPUT_LINES);
|
|
114
|
+
const skipped = lines.length - MAX_OUTPUT_LINES;
|
|
115
|
+
return `[${skipped} lines truncated]\n${kept.join("\n")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (Buffer.byteLength(text, "utf-8") > MAX_OUTPUT_BYTES) {
|
|
119
|
+
const buf = Buffer.from(text, "utf-8");
|
|
120
|
+
return buf.subarray(buf.length - MAX_OUTPUT_BYTES).toString("utf-8");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return text;
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { resolve, isAbsolute } from "node:path";
|
|
5
|
+
|
|
6
|
+
const schema = Type.Object({
|
|
7
|
+
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
|
8
|
+
oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
|
|
9
|
+
newText: Type.String({ description: "New text to replace the old text with" }),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function createEditTool(cwd: string): AgentTool<typeof schema> {
|
|
13
|
+
return {
|
|
14
|
+
name: "edit",
|
|
15
|
+
label: "edit",
|
|
16
|
+
description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace).",
|
|
17
|
+
parameters: schema,
|
|
18
|
+
async execute(_toolCallId, { path, oldText, newText }, signal) {
|
|
19
|
+
const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
|
|
20
|
+
|
|
21
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
22
|
+
|
|
23
|
+
const content = await readFile(absolutePath, "utf-8");
|
|
24
|
+
|
|
25
|
+
const index = content.indexOf(oldText);
|
|
26
|
+
if (index === -1) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Could not find the exact text in ${path}. The old text must match exactly including whitespace and newlines.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check for multiple occurrences
|
|
33
|
+
const secondIndex = content.indexOf(oldText, index + 1);
|
|
34
|
+
if (secondIndex !== -1) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Found multiple occurrences of the text in ${path}. Provide more context to make it unique.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
|
|
41
|
+
|
|
42
|
+
await writeFile(absolutePath, newContent, "utf-8");
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: `Edited ${path}` }],
|
|
46
|
+
details: {},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { resolve, isAbsolute, join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
const MAX_RESULTS = 200;
|
|
7
|
+
const MAX_FILE_SIZE = 1024 * 1024; // Skip files > 1MB
|
|
8
|
+
|
|
9
|
+
const schema = Type.Object({
|
|
10
|
+
pattern: Type.String({ description: "Regex pattern to search for" }),
|
|
11
|
+
path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
|
|
12
|
+
include: Type.Optional(Type.String({ description: "Glob-like file extension filter, e.g. '*.ts'" })),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function createGrepTool(cwd: string): AgentTool<typeof schema> {
|
|
16
|
+
return {
|
|
17
|
+
name: "grep",
|
|
18
|
+
label: "grep",
|
|
19
|
+
description: `Search file contents with regex. Returns matching lines with file paths and line numbers. Max ${MAX_RESULTS} results.`,
|
|
20
|
+
parameters: schema,
|
|
21
|
+
async execute(_toolCallId, { pattern, path, include }, signal) {
|
|
22
|
+
const searchDir = path ? (isAbsolute(path) ? path : resolve(cwd, path)) : cwd;
|
|
23
|
+
|
|
24
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
25
|
+
|
|
26
|
+
let regex: RegExp;
|
|
27
|
+
try {
|
|
28
|
+
regex = new RegExp(pattern, "g");
|
|
29
|
+
} catch (e: any) {
|
|
30
|
+
throw new Error(`Invalid regex pattern: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const extFilter = include ? parseExtFilter(include) : null;
|
|
34
|
+
const results: string[] = [];
|
|
35
|
+
|
|
36
|
+
await searchDirectory(searchDir, regex, extFilter, results, cwd, signal);
|
|
37
|
+
|
|
38
|
+
if (results.length === 0) {
|
|
39
|
+
return { content: [{ type: "text", text: "No matches found." }], details: {} };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let output = results.join("\n");
|
|
43
|
+
if (results.length >= MAX_RESULTS) {
|
|
44
|
+
output += `\n\n[Truncated at ${MAX_RESULTS} results]`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: output }],
|
|
49
|
+
details: {},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseExtFilter(include: string): string | null {
|
|
56
|
+
// Handle "*.ts" → ".ts"
|
|
57
|
+
if (include.startsWith("*.")) {
|
|
58
|
+
return include.substring(1);
|
|
59
|
+
}
|
|
60
|
+
return include;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SKIP_DIRS = new Set([
|
|
64
|
+
"node_modules", ".git", "dist", "build", ".next", "__pycache__",
|
|
65
|
+
".venv", "venv", ".cache", "coverage",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
async function searchDirectory(
|
|
69
|
+
dir: string,
|
|
70
|
+
regex: RegExp,
|
|
71
|
+
extFilter: string | null,
|
|
72
|
+
results: string[],
|
|
73
|
+
cwd: string,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (results.length >= MAX_RESULTS) return;
|
|
77
|
+
if (signal?.aborted) return;
|
|
78
|
+
|
|
79
|
+
let entries: string[];
|
|
80
|
+
try {
|
|
81
|
+
entries = await readdir(dir);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (results.length >= MAX_RESULTS) return;
|
|
88
|
+
if (signal?.aborted) return;
|
|
89
|
+
|
|
90
|
+
const fullPath = join(dir, entry);
|
|
91
|
+
|
|
92
|
+
let s;
|
|
93
|
+
try {
|
|
94
|
+
s = await stat(fullPath);
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (s.isDirectory()) {
|
|
100
|
+
if (!SKIP_DIRS.has(entry)) {
|
|
101
|
+
await searchDirectory(fullPath, regex, extFilter, results, cwd, signal);
|
|
102
|
+
}
|
|
103
|
+
} else if (s.isFile()) {
|
|
104
|
+
if (s.size > MAX_FILE_SIZE) continue;
|
|
105
|
+
if (extFilter && !entry.endsWith(extFilter)) continue;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const content = await readFile(fullPath, "utf-8");
|
|
109
|
+
const lines = content.split("\n");
|
|
110
|
+
const relPath = relative(cwd, fullPath);
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
regex.lastIndex = 0;
|
|
114
|
+
if (regex.test(lines[i])) {
|
|
115
|
+
results.push(`${relPath}:${i + 1}: ${lines[i].trimEnd()}`);
|
|
116
|
+
if (results.length >= MAX_RESULTS) return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip binary/unreadable files
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { createReadTool } from "./read.js";
|
|
3
|
+
import { createWriteTool } from "./write.js";
|
|
4
|
+
import { createEditTool } from "./edit.js";
|
|
5
|
+
import { createLsTool } from "./ls.js";
|
|
6
|
+
import { createGrepTool } from "./grep.js";
|
|
7
|
+
import { createBashTool } from "./bash.js";
|
|
8
|
+
|
|
9
|
+
export function createAllTools(cwd: string): AgentTool<any>[] {
|
|
10
|
+
return [
|
|
11
|
+
createReadTool(cwd),
|
|
12
|
+
createWriteTool(cwd),
|
|
13
|
+
createEditTool(cwd),
|
|
14
|
+
createLsTool(cwd),
|
|
15
|
+
createGrepTool(cwd),
|
|
16
|
+
createBashTool(cwd),
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { createReadTool } from "./read.js";
|
|
21
|
+
export { createWriteTool } from "./write.js";
|
|
22
|
+
export { createEditTool } from "./edit.js";
|
|
23
|
+
export { createLsTool } from "./ls.js";
|
|
24
|
+
export { createGrepTool } from "./grep.js";
|
|
25
|
+
export { createBashTool } from "./bash.js";
|
package/src/tools/ls.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { readdir, stat } from "node:fs/promises";
|
|
4
|
+
import { resolve, isAbsolute, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const schema = Type.Object({
|
|
7
|
+
path: Type.Optional(Type.String({ description: "Directory path (default: current directory)" })),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export function createLsTool(cwd: string): AgentTool<typeof schema> {
|
|
11
|
+
return {
|
|
12
|
+
name: "ls",
|
|
13
|
+
label: "ls",
|
|
14
|
+
description: "List directory contents with file types and sizes.",
|
|
15
|
+
parameters: schema,
|
|
16
|
+
async execute(_toolCallId, { path }, signal) {
|
|
17
|
+
const dirPath = path ? (isAbsolute(path) ? path : resolve(cwd, path)) : cwd;
|
|
18
|
+
|
|
19
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
20
|
+
|
|
21
|
+
const entries = await readdir(dirPath);
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
|
|
24
|
+
for (const entry of entries.sort()) {
|
|
25
|
+
try {
|
|
26
|
+
const fullPath = join(dirPath, entry);
|
|
27
|
+
const s = await stat(fullPath);
|
|
28
|
+
const type = s.isDirectory() ? "dir" : "file";
|
|
29
|
+
const size = s.isDirectory() ? "-" : formatSize(s.size);
|
|
30
|
+
lines.push(`${type}\t${size}\t${entry}`);
|
|
31
|
+
} catch {
|
|
32
|
+
lines.push(`?\t?\t${entry}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (lines.length === 0) {
|
|
37
|
+
return { content: [{ type: "text", text: "(empty directory)" }], details: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
42
|
+
details: {},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatSize(bytes: number): string {
|
|
49
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
50
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
51
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { resolve, isAbsolute } from "node:path";
|
|
5
|
+
|
|
6
|
+
const MAX_LINES = 2000;
|
|
7
|
+
const MAX_BYTES = 256 * 1024;
|
|
8
|
+
|
|
9
|
+
const schema = Type.Object({
|
|
10
|
+
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
|
11
|
+
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
|
12
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function createReadTool(cwd: string): AgentTool<typeof schema> {
|
|
16
|
+
return {
|
|
17
|
+
name: "read",
|
|
18
|
+
label: "read",
|
|
19
|
+
description: `Read file contents. Output truncated to ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB. Use offset/limit for large files.`,
|
|
20
|
+
parameters: schema,
|
|
21
|
+
async execute(_toolCallId, { path, offset, limit }, signal) {
|
|
22
|
+
const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
|
|
23
|
+
|
|
24
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
25
|
+
|
|
26
|
+
const buffer = await readFile(absolutePath);
|
|
27
|
+
const text = buffer.toString("utf-8");
|
|
28
|
+
const allLines = text.split("\n");
|
|
29
|
+
const totalLines = allLines.length;
|
|
30
|
+
|
|
31
|
+
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
|
32
|
+
if (startLine >= totalLines) {
|
|
33
|
+
throw new Error(`Offset ${offset} is beyond end of file (${totalLines} lines)`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let selectedLines: string[];
|
|
37
|
+
if (limit !== undefined) {
|
|
38
|
+
selectedLines = allLines.slice(startLine, startLine + limit);
|
|
39
|
+
} else {
|
|
40
|
+
selectedLines = allLines.slice(startLine);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Truncate by lines
|
|
44
|
+
if (selectedLines.length > MAX_LINES) {
|
|
45
|
+
selectedLines = selectedLines.slice(0, MAX_LINES);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let output = selectedLines.join("\n");
|
|
49
|
+
|
|
50
|
+
// Truncate by bytes
|
|
51
|
+
if (Buffer.byteLength(output, "utf-8") > MAX_BYTES) {
|
|
52
|
+
const buf = Buffer.from(output, "utf-8");
|
|
53
|
+
output = buf.subarray(0, MAX_BYTES).toString("utf-8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const shownLines = output.split("\n").length;
|
|
57
|
+
const remaining = totalLines - startLine - shownLines;
|
|
58
|
+
if (remaining > 0) {
|
|
59
|
+
const nextOffset = startLine + shownLines + 1;
|
|
60
|
+
output += `\n\n[Showing ${shownLines} of ${totalLines} lines. Use offset=${nextOffset} to continue.]`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: output }],
|
|
65
|
+
details: {},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { resolve, isAbsolute, dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
const schema = Type.Object({
|
|
7
|
+
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
|
8
|
+
content: Type.String({ description: "Content to write to the file" }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function createWriteTool(cwd: string): AgentTool<typeof schema> {
|
|
12
|
+
return {
|
|
13
|
+
name: "write",
|
|
14
|
+
label: "write",
|
|
15
|
+
description: "Write content to a file. Creates the file and parent directories if they don't exist, overwrites if they do.",
|
|
16
|
+
parameters: schema,
|
|
17
|
+
async execute(_toolCallId, { path, content }, signal) {
|
|
18
|
+
const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
|
|
19
|
+
|
|
20
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
21
|
+
|
|
22
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
23
|
+
await writeFile(absolutePath, content, "utf-8");
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }],
|
|
27
|
+
details: {},
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|