diff --git a/.changeset/remove-redundant-tools.md b/.changeset/remove-redundant-tools.md new file mode 100644 index 00000000..23d84104 --- /dev/null +++ b/.changeset/remove-redundant-tools.md @@ -0,0 +1,8 @@ +--- +"@perstack/base": patch +"@perstack/core": patch +"@perstack/tui-components": patch +"@perstack/runtime": patch +--- + +Remove 8 redundant base tools (healthCheck, appendTextFile, createDirectory, deleteDirectory, deleteFile, getFileInfo, listDirectory, moveFile) and slim down remaining tool descriptions diff --git a/apps/base/README.md b/apps/base/README.md index 5caa8a61..3cefddb2 100644 --- a/apps/base/README.md +++ b/apps/base/README.md @@ -39,22 +39,12 @@ registerWriteTextFile(server) ### File Operations - `readTextFile` - Read text files with optional line range - `writeTextFile` - Create or overwrite text files -- `appendTextFile` - Append content to existing files - `editTextFile` - Replace text in existing files -- `deleteFile` - Remove files -- `moveFile` - Move or rename files -- `getFileInfo` - Get file metadata - `readImageFile` - Read image files (PNG, JPEG, GIF, WebP) - `readPdfFile` - Read PDF files -### Directory Operations -- `listDirectory` - List directory contents -- `createDirectory` - Create directories -- `deleteDirectory` - Remove directories - ### Utilities - `exec` - Execute system commands -- `healthCheck` - Check Perstack runtime health status - `todo` - Task list management - `clearTodo` - Clear task list - `attemptCompletion` - Signal task completion (validates todos first) diff --git a/apps/base/package.json b/apps/base/package.json index 486ec5e6..98c69373 100644 --- a/apps/base/package.json +++ b/apps/base/package.json @@ -33,7 +33,6 @@ "@perstack/core": "workspace:*", "commander": "^14.0.3", "mime-types": "^3.0.2", - "ts-dedent": "^2.2.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/base/src/index.ts b/apps/base/src/index.ts index acf24858..72975ff8 100644 --- a/apps/base/src/index.ts +++ b/apps/base/src/index.ts @@ -6,16 +6,9 @@ export { createBaseServer, registerAllTools, } from "./server.js" -export * from "./tools/append-text-file.js" export * from "./tools/attempt-completion.js" -export * from "./tools/create-directory.js" -export * from "./tools/delete-directory.js" -export * from "./tools/delete-file.js" export * from "./tools/edit-text-file.js" export * from "./tools/exec.js" -export * from "./tools/get-file-info.js" -export * from "./tools/list-directory.js" -export * from "./tools/move-file.js" export * from "./tools/read-image-file.js" export * from "./tools/read-pdf-file.js" export * from "./tools/read-text-file.js" diff --git a/apps/base/src/lib/safe-file.test.ts b/apps/base/src/lib/safe-file.test.ts index b708fdec..ab3017f5 100644 --- a/apps/base/src/lib/safe-file.test.ts +++ b/apps/base/src/lib/safe-file.test.ts @@ -1,11 +1,6 @@ import fs from "node:fs/promises" import { afterEach, describe, expect, it } from "vitest" -import { - isSymlinkProtectionFullySupported, - safeAppendFile, - safeReadFile, - safeWriteFile, -} from "./safe-file.js" +import { isSymlinkProtectionFullySupported, safeReadFile, safeWriteFile } from "./safe-file.js" const testFile = "safe-file.test.txt" const testSymlink = "safe-file.test.link" @@ -67,29 +62,6 @@ describe("safe-file", () => { }) }) - describe("safeAppendFile", () => { - it("appends content to file", async () => { - await fs.writeFile(testFile, "hello") - await safeAppendFile(testFile, " world") - const content = await fs.readFile(testFile, "utf-8") - expect(content).toBe("hello world") - }) - - it("rejects symbolic links", async () => { - await fs.writeFile(testFile, "content") - try { - await fs.symlink(testFile, testSymlink) - await expect(safeAppendFile(testSymlink, "malicious")).rejects.toThrow("symbolic link") - } catch (error) { - const err = error as { code?: string } - if (err.code === "EPERM" || err.code === "ENOTSUP") { - return - } - throw error - } - }) - }) - describe("isSymlinkProtectionFullySupported", () => { it("returns boolean indicating O_NOFOLLOW support", () => { const result = isSymlinkProtectionFullySupported() diff --git a/apps/base/src/lib/safe-file.ts b/apps/base/src/lib/safe-file.ts index ce5c59f7..acf71609 100644 --- a/apps/base/src/lib/safe-file.ts +++ b/apps/base/src/lib/safe-file.ts @@ -39,15 +39,3 @@ export async function safeReadFile(path: string): Promise { await handle?.close() } } - -export async function safeAppendFile(path: string, data: string): Promise { - let handle: FileHandle | undefined - try { - await checkNotSymlink(path) - const flags = constants.O_WRONLY | constants.O_APPEND | O_NOFOLLOW - handle = await open(path, flags) - await handle.writeFile(data, "utf-8") - } finally { - await handle?.close() - } -} diff --git a/apps/base/src/server.ts b/apps/base/src/server.ts index 8af713e3..ffb9b05d 100644 --- a/apps/base/src/server.ts +++ b/apps/base/src/server.ts @@ -1,16 +1,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import packageJson from "../package.json" with { type: "json" } -import { registerAppendTextFile } from "./tools/append-text-file.js" import { registerAttemptCompletion } from "./tools/attempt-completion.js" -import { registerCreateDirectory } from "./tools/create-directory.js" -import { registerDeleteDirectory } from "./tools/delete-directory.js" -import { registerDeleteFile } from "./tools/delete-file.js" import { registerEditTextFile } from "./tools/edit-text-file.js" import { registerExec } from "./tools/exec.js" -import { registerGetFileInfo } from "./tools/get-file-info.js" -import { registerHealthCheck } from "./tools/health-check.js" -import { registerListDirectory } from "./tools/list-directory.js" -import { registerMoveFile } from "./tools/move-file.js" import { registerReadImageFile } from "./tools/read-image-file.js" import { registerReadPdfFile } from "./tools/read-pdf-file.js" import { registerReadTextFile } from "./tools/read-text-file.js" @@ -32,19 +24,11 @@ export function registerAllTools(server: McpServer): void { registerTodo(server) registerClearTodo(server) registerExec(server) - registerGetFileInfo(server) - registerHealthCheck(server) registerReadTextFile(server) registerReadImageFile(server) registerReadPdfFile(server) registerWriteTextFile(server) - registerAppendTextFile(server) registerEditTextFile(server) - registerMoveFile(server) - registerDeleteFile(server) - registerListDirectory(server) - registerCreateDirectory(server) - registerDeleteDirectory(server) } /** diff --git a/apps/base/src/tools/append-text-file.test.ts b/apps/base/src/tools/append-text-file.test.ts deleted file mode 100644 index 6822e195..00000000 --- a/apps/base/src/tools/append-text-file.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import fs from "node:fs/promises" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { appendTextFile } from "./append-text-file.js" - -const testFile = "append-text-file.test.txt" -vi.mock("../lib/path.js", () => ({ - workspacePath: "/workspace", - validatePath: vi.fn(), -})) - -const mockPath = vi.mocked(await import("../lib/path.js")) - -describe("appendTextFile tool", () => { - beforeEach(async () => { - await fs.writeFile(testFile, "initial content") - }) - - afterEach(async () => { - await fs.rm(testFile, { force: true }) - vi.clearAllMocks() - }) - - it("appends text to existing file", async () => { - const appendText = "\nappended content" - mockPath.validatePath.mockResolvedValue(testFile) - const result = await appendTextFile({ path: testFile, text: appendText }) - expect(await fs.readFile(testFile, "utf-8")).toBe("initial content\nappended content") - expect(result).toStrictEqual({ path: testFile, text: appendText }) - }) - - it("appends multiple times", async () => { - mockPath.validatePath.mockResolvedValue(testFile) - await appendTextFile({ path: testFile, text: "\nfirst append" }) - await appendTextFile({ path: testFile, text: "\nsecond append" }) - const content = await fs.readFile(testFile, "utf-8") - expect(content).toBe("initial content\nfirst append\nsecond append") - }) - - it("throws error if file does not exist", async () => { - const nonExistentFile = "non-existent.txt" - mockPath.validatePath.mockResolvedValue(nonExistentFile) - await expect(appendTextFile({ path: nonExistentFile, text: "content" })).rejects.toThrow( - "does not exist", - ) - }) - - it("throws error if file is not writable", async () => { - await fs.chmod(testFile, 0o444) - mockPath.validatePath.mockResolvedValue(testFile) - await expect(appendTextFile({ path: testFile, text: "new content" })).rejects.toThrow( - "not writable", - ) - await fs.chmod(testFile, 0o644) - }) - - it("handles empty append text", async () => { - mockPath.validatePath.mockResolvedValue(testFile) - const result = await appendTextFile({ path: testFile, text: "" }) - expect(await fs.readFile(testFile, "utf-8")).toBe("initial content") - expect(result).toStrictEqual({ path: testFile, text: "" }) - }) -}) diff --git a/apps/base/src/tools/append-text-file.ts b/apps/base/src/tools/append-text-file.ts deleted file mode 100644 index abc354f4..00000000 --- a/apps/base/src/tools/append-text-file.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { stat } from "node:fs/promises" -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" -import { z } from "zod/v4" -import { validatePath } from "../lib/path.js" -import { safeAppendFile } from "../lib/safe-file.js" -import { errorToolResult, successToolResult } from "../lib/tool-result.js" - -export async function appendTextFile({ path, text }: { path: string; text: string }) { - const validatedPath = await validatePath(path) - const stats = await stat(validatedPath).catch(() => null) - if (!stats) { - throw new Error(`File ${path} does not exist.`) - } - if (!(stats.mode & 0o200)) { - throw new Error(`File ${path} is not writable`) - } - await safeAppendFile(validatedPath, text) - return { path: validatedPath, text } -} - -export function registerAppendTextFile(server: McpServer) { - server.registerTool( - "appendTextFile", - { - title: "Append text file", - description: dedent` - Adding content to the end of existing files. - - Use cases: - - Adding entries to log files - - Appending data to CSV or JSON files - - Adding new sections to documentation - - Extending configuration files - - Building files incrementally - - How it works: - - Appends text to the end of an existing file - - Does not modify existing content - - Creates a new line before appending if needed - - Returns the appended file path - - Rules: - - FILE MUST EXIST BEFORE APPENDING - - YOU MUST PROVIDE A VALID UTF-8 STRING FOR THE TEXT - `, - inputSchema: { - path: z.string().describe("Target file path to append to."), - text: z.string().describe("Text to append to the file."), - }, - }, - async ({ path, text }: { path: string; text: string }) => { - try { - return successToolResult(await appendTextFile({ path, text })) - } catch (e) { - if (e instanceof Error) return errorToolResult(e) - throw e - } - }, - ) -} diff --git a/apps/base/src/tools/attempt-completion.ts b/apps/base/src/tools/attempt-completion.ts index 597a68cb..a3b5f5f4 100644 --- a/apps/base/src/tools/attempt-completion.ts +++ b/apps/base/src/tools/attempt-completion.ts @@ -1,5 +1,4 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" import { errorToolResult, successToolResult } from "../lib/tool-result.js" import { getRemainingTodos } from "./todo.js" @@ -20,21 +19,7 @@ export function registerAttemptCompletion(server: McpServer) { "attemptCompletion", { title: "Attempt completion", - description: dedent` - Task completion signal with automatic todo validation. - Use cases: - - Signaling task completion to Perstack runtime - - Validating all todos are complete before ending - - Ending the current expert's work cycle - How it works: - - Checks the current todo list for incomplete items - - If incomplete todos exist: returns them and continues the agent loop - - If no incomplete todos: returns empty object and ends the agent loop - Notes: - - Mark all todos as complete before calling - - Use clearTodo if you want to reset and start fresh - - Prevents premature completion by surfacing forgotten tasks - `, + description: "Signal task completion. Validates all todos are complete before ending.", inputSchema: {}, }, async () => { diff --git a/apps/base/src/tools/create-directory.test.ts b/apps/base/src/tools/create-directory.test.ts deleted file mode 100644 index f3b4bfa1..00000000 --- a/apps/base/src/tools/create-directory.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { existsSync, statSync } from "node:fs" -import fs from "node:fs/promises" -import { afterEach, describe, expect, it } from "vitest" -import { validatePath } from "../lib/path.js" -import { createDirectory } from "./create-directory.js" - -const dirName = "create-directory-test" -describe("createDirectory tool", () => { - afterEach(async () => { - await fs.rm(dirName, { recursive: true, force: true }) - }) - - it("creates new directory successfully", async () => { - const newDir = await createDirectory({ path: dirName }) - expect(existsSync(newDir.path)).toBe(true) - const stats = statSync(newDir.path) - expect(stats.isDirectory()).toBe(true) - expect(stats.mode & 0o200).toBeTruthy() - }) - - it("throws error if directory already exists", async () => { - await fs.mkdir(dirName) - await expect(createDirectory({ path: dirName })).rejects.toThrow( - `Directory ${dirName} already exists`, - ) - }) - - it("throws error if parent directory is not writable", async () => { - await fs.mkdir(dirName) - await fs.chmod(dirName, 0o444) - const validatedPath = await validatePath(dirName) - await expect(createDirectory({ path: `${dirName}/new-dir` })).rejects.toThrow( - `Parent directory ${validatedPath} is not writable`, - ) - }) -}) diff --git a/apps/base/src/tools/create-directory.ts b/apps/base/src/tools/create-directory.ts deleted file mode 100644 index b77df66d..00000000 --- a/apps/base/src/tools/create-directory.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { existsSync, statSync } from "node:fs" -import { mkdir } from "node:fs/promises" -import { dirname } from "node:path" -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" -import { z } from "zod/v4" -import { validatePath } from "../lib/path.js" -import { errorToolResult, successToolResult } from "../lib/tool-result.js" -export async function createDirectory(input: { path: string }) { - const { path } = input - const validatedPath = await validatePath(path) - const exists = existsSync(validatedPath) - if (exists) { - throw new Error(`Directory ${path} already exists`) - } - const parentDir = dirname(validatedPath) - if (existsSync(parentDir)) { - const parentStats = statSync(parentDir) - if (!(parentStats.mode & 0o200)) { - throw new Error(`Parent directory ${parentDir} is not writable`) - } - } - await mkdir(validatedPath, { recursive: true }) - return { - path: validatedPath, - } -} - -export function registerCreateDirectory(server: McpServer) { - server.registerTool( - "createDirectory", - { - title: "Create directory", - description: dedent` - Directory creator for establishing folder structures in the workspace. - - Use cases: - - Setting up project directory structure - - Creating output folders for generated content - - Organizing files into logical groups - - Preparing directory hierarchies - - How it works: - - Creates directories recursively - - Handles existing directories gracefully - - Creates parent directories as needed - - Returns creation status - - Parameters: - - path: Directory path to create - `, - inputSchema: { - path: z.string(), - }, - }, - async (input: { path: string }) => { - try { - return successToolResult(await createDirectory(input)) - } catch (e) { - if (e instanceof Error) return errorToolResult(e) - throw e - } - }, - ) -} diff --git a/apps/base/src/tools/delete-directory.test.ts b/apps/base/src/tools/delete-directory.test.ts deleted file mode 100644 index 7b69f30d..00000000 --- a/apps/base/src/tools/delete-directory.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { existsSync } from "node:fs" -import fs from "node:fs/promises" -import { afterEach, describe, expect, it } from "vitest" -import { validatePath } from "../lib/path.js" -import { deleteDirectory } from "./delete-directory.js" - -const testDir = "delete-directory-test" - -describe("deleteDirectory tool", () => { - afterEach(async () => { - await fs.rm(testDir, { recursive: true, force: true }) - }) - - it("deletes empty directory successfully", async () => { - await fs.mkdir(testDir) - const result = await deleteDirectory({ path: testDir }) - expect(existsSync(testDir)).toBe(false) - expect(result).toStrictEqual({ path: await validatePath(testDir) }) - }) - - it("deletes non-empty directory with recursive flag", async () => { - await fs.mkdir(testDir) - await fs.writeFile(`${testDir}/file.txt`, "content") - const result = await deleteDirectory({ path: testDir, recursive: true }) - expect(existsSync(testDir)).toBe(false) - expect(result).toStrictEqual({ path: await validatePath(testDir) }) - }) - - it("throws error if directory does not exist", async () => { - await expect(deleteDirectory({ path: "nonexistent" })).rejects.toThrow("does not exist") - }) - - it("throws error if path is a file", async () => { - await fs.mkdir(testDir) - const filePath = `${testDir}/file.txt` - await fs.writeFile(filePath, "content") - await expect(deleteDirectory({ path: filePath })).rejects.toThrow("is not a directory") - }) - - it("throws error for non-empty directory without recursive flag", async () => { - await fs.mkdir(testDir) - await fs.writeFile(`${testDir}/file.txt`, "content") - await expect(deleteDirectory({ path: testDir })).rejects.toThrow() - }) -}) diff --git a/apps/base/src/tools/delete-directory.ts b/apps/base/src/tools/delete-directory.ts deleted file mode 100644 index f4135064..00000000 --- a/apps/base/src/tools/delete-directory.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { existsSync, statSync } from "node:fs" -import { rm, rmdir } from "node:fs/promises" -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" -import { z } from "zod/v4" -import { validatePath } from "../lib/path.js" -import { errorToolResult, successToolResult } from "../lib/tool-result.js" -export async function deleteDirectory(input: { path: string; recursive?: boolean }) { - const { path, recursive } = input - const validatedPath = await validatePath(path) - if (!existsSync(validatedPath)) { - throw new Error(`Directory ${path} does not exist.`) - } - const stats = statSync(validatedPath) - if (!stats.isDirectory()) { - throw new Error(`Path ${path} is not a directory. Use deleteFile tool instead.`) - } - if (!(stats.mode & 0o200)) { - throw new Error(`Directory ${path} is not writable`) - } - if (recursive) { - await rm(validatedPath, { recursive: true }) - } else { - await rmdir(validatedPath) - } - return { - path: validatedPath, - } -} - -export function registerDeleteDirectory(server: McpServer) { - server.registerTool( - "deleteDirectory", - { - title: "Delete directory", - description: dedent` - Directory deleter for removing directories from the workspace. - - Use cases: - - Removing temporary directories - - Cleaning up build artifacts - - Deleting empty directories after moving files - - How it works: - - Validates directory existence and permissions - - Removes directory (and contents if recursive is true) - - Returns deletion status - - Parameters: - - path: Directory path to delete - - recursive: Set to true to delete non-empty directories - `, - inputSchema: { - path: z.string(), - recursive: z - .boolean() - .optional() - .describe("Whether to delete contents recursively. Required for non-empty directories."), - }, - }, - async (input: { path: string; recursive?: boolean }) => { - try { - return successToolResult(await deleteDirectory(input)) - } catch (e) { - if (e instanceof Error) return errorToolResult(e) - throw e - } - }, - ) -} diff --git a/apps/base/src/tools/delete-file.test.ts b/apps/base/src/tools/delete-file.test.ts deleted file mode 100644 index 41ddaf35..00000000 --- a/apps/base/src/tools/delete-file.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { existsSync } from "node:fs" -import fs from "node:fs/promises" -import { afterEach, describe, expect, it } from "vitest" -import { validatePath } from "../lib/path.js" -import { deleteFile } from "./delete-file.js" - -const testFile = "delete-file.test.txt" -describe("deleteFile tool", () => { - afterEach(async () => { - await fs.rm(testFile, { force: true }) - }) - - it("deletes existing file successfully", async () => { - await fs.writeFile(testFile, "test content") - const result = await deleteFile({ path: testFile }) - expect(existsSync(testFile)).toBe(false) - expect(result).toStrictEqual({ path: await validatePath(testFile) }) - }) - - it("throws error if file does not exist", async () => { - await expect(deleteFile({ path: "nonexistent.txt" })).rejects.toThrow("does not exist") - }) - - it("throws error if path is a directory", async () => { - const testDir = "test-directory" - if (existsSync(testDir)) { - await fs.rmdir(testDir) - } - await fs.mkdir(testDir) - await expect(deleteFile({ path: testDir })).rejects.toThrow("is a directory") - if (existsSync(testDir)) { - await fs.rmdir(testDir) - } - }) - - it("throws error if file is not writable", async () => { - await fs.writeFile(testFile, "readonly content") - await fs.chmod(testFile, 0o444) - await expect(deleteFile({ path: testFile })).rejects.toThrow("not writable") - await fs.chmod(testFile, 0o644) - }) -}) diff --git a/apps/base/src/tools/delete-file.ts b/apps/base/src/tools/delete-file.ts deleted file mode 100644 index 750cba0b..00000000 --- a/apps/base/src/tools/delete-file.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { existsSync, statSync } from "node:fs" -import { unlink } from "node:fs/promises" -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" -import { z } from "zod/v4" -import { validatePath } from "../lib/path.js" -import { errorToolResult, successToolResult } from "../lib/tool-result.js" -export async function deleteFile(input: { path: string }) { - const { path } = input - const validatedPath = await validatePath(path) - if (!existsSync(validatedPath)) { - throw new Error(`File ${path} does not exist.`) - } - const stats = statSync(validatedPath) - if (stats.isDirectory()) { - throw new Error(`Path ${path} is a directory. Use delete directory tool instead.`) - } - if (!(stats.mode & 0o200)) { - throw new Error(`File ${path} is not writable`) - } - await unlink(validatedPath) - return { - path: validatedPath, - } -} - -export function registerDeleteFile(server: McpServer) { - server.registerTool( - "deleteFile", - { - title: "Delete file", - description: dedent` - File deleter for removing files from the workspace. - - Use cases: - - Removing temporary files - - Cleaning up generated files - - Deleting outdated configuration files - - Removing unwanted artifacts - - How it works: - - Validates file existence and permissions - - Performs atomic delete operation - - Returns deletion status - - Parameters: - - path: File path to delete - `, - inputSchema: { - path: z.string(), - }, - }, - async (input: { path: string }) => { - try { - return successToolResult(await deleteFile(input)) - } catch (e) { - if (e instanceof Error) return errorToolResult(e) - throw e - } - }, - ) -} diff --git a/apps/base/src/tools/edit-text-file.ts b/apps/base/src/tools/edit-text-file.ts index b273655e..f94ec614 100644 --- a/apps/base/src/tools/edit-text-file.ts +++ b/apps/base/src/tools/edit-text-file.ts @@ -1,6 +1,5 @@ import { stat } from "node:fs/promises" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" import { z } from "zod/v4" import { validatePath } from "../lib/path.js" import { safeReadFile, safeWriteFile } from "../lib/safe-file.js" @@ -44,26 +43,7 @@ export function registerEditTextFile(server: McpServer) { "editTextFile", { title: "Edit text file", - description: dedent` - Text file editor for modifying existing files with precise text replacement. - - Use cases: - - Updating configuration values - - Modifying code snippets - - Replacing specific text blocks - - Making targeted edits to files - - How it works: - - Reads existing file content - - Performs exact text replacement of oldText with newText - - Normalizes line endings for consistent behavior - - Returns summary of changes made - - For appending text to files, use the appendTextFile tool instead - - Rules: - - YOU MUST PROVIDE A VALID UTF-8 STRING FOR THE TEXT - - DO NOT USE THIS TOOL FOR APPENDING TEXT TO FILES - USE appendTextFile TOOL INSTEAD - `, + description: "Replace exact text in an existing file. Normalizes line endings (CRLF → LF).", inputSchema: { path: z.string().describe("Target file path to edit."), newText: z.string().describe("Text to replace with."), diff --git a/apps/base/src/tools/exec.ts b/apps/base/src/tools/exec.ts index 9869748e..8993e566 100644 --- a/apps/base/src/tools/exec.ts +++ b/apps/base/src/tools/exec.ts @@ -2,7 +2,6 @@ import { type ExecException, execFile } from "node:child_process" import { promisify } from "node:util" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { getFilteredEnv } from "@perstack/core" -import { dedent } from "ts-dedent" import { z } from "zod/v4" import { validatePath } from "../lib/path.js" import { successToolResult } from "../lib/tool-result.js" @@ -47,33 +46,7 @@ export function registerExec(server: McpServer) { "exec", { title: "Execute Command", - description: dedent` - Command executor for running system commands and scripts. - - Use cases: - - Running system tasks or scripts - - Automating command-line tools or utilities - - Executing build commands or test runners - - How it works: - - Executes the specified command with arguments - - Captures stdout and/or stderr based on flags - - Returns command output or error information - - Parameters: - - command: The command to execute (e.g., ls, python) - - args: Arguments to pass to the command - - env: Environment variables for the execution - - cwd: Working directory for command execution - - stdout: Whether to capture standard output - - stderr: Whether to capture standard error - - timeout: Timeout in milliseconds (optional) - - Rules: - - Only execute commands from trusted sources - - Do not execute long-running foreground commands (e.g., tail -f) - - Be cautious with resource-intensive commands - `, + description: "Execute a system command. Returns stdout/stderr.", inputSchema: { command: z.string().describe("The command to execute"), args: z.array(z.string()).describe("The arguments to pass to the command"), diff --git a/apps/base/src/tools/get-file-info.test.ts b/apps/base/src/tools/get-file-info.test.ts deleted file mode 100644 index 364126fb..00000000 --- a/apps/base/src/tools/get-file-info.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import fs from "node:fs/promises" -import { afterEach, describe, expect, it } from "vitest" -import { validatePath } from "../lib/path.js" -import { getFileInfo } from "./get-file-info.js" - -const testFile = "get-file-info.test.txt" -const testDir = "test-directory" -describe("getFileInfo tool", () => { - afterEach(async () => { - await fs.rm(testFile, { force: true }) - await fs.rm(testDir, { recursive: true, force: true }) - }) - - it("gets info for existing text file", async () => { - const content = "Hello, World!" - await fs.writeFile(testFile, content) - const result = await getFileInfo({ path: testFile }) - expect(result.exists).toBe(true) - expect(result.path).toBe(await validatePath(testFile)) - expect(result.name).toBe(testFile) - expect(result.extension).toBe(".txt") - expect(result.type).toBe("file") - expect(result.mimeType).toBe("text/plain") - expect(result.size).toBe(content.length) - expect(result.sizeFormatted).toBe("13.00 B") - expect(result.permissions.readable).toBe(true) - expect(result.permissions.writable).toBe(true) - }) - - it("gets info for existing directory", async () => { - await fs.mkdir(testDir) - const result = await getFileInfo({ path: testDir }) - expect(result.exists).toBe(true) - expect(result.name).toBe(testDir) - expect(result.extension).toBe(null) - expect(result.type).toBe("directory") - expect(result.mimeType).toBe(null) - expect(result.permissions.readable).toBe(true) - }) - - it("throws error if file does not exist", async () => { - await expect(getFileInfo({ path: "nonexistent.txt" })).rejects.toThrow("does not exist") - }) - - it("detects read-only files", async () => { - await fs.writeFile(testFile, "readonly content") - await fs.chmod(testFile, 0o444) - const result = await getFileInfo({ path: testFile }) - expect(result.permissions.readable).toBe(true) - expect(result.permissions.writable).toBe(false) - await fs.chmod(testFile, 0o644) - }) - - it("formats large file sizes correctly", async () => { - const largeContent = "x".repeat(1024 * 1024) - await fs.writeFile(testFile, largeContent) - const result = await getFileInfo({ path: testFile }) - expect(result.size).toBe(1024 * 1024) - expect(result.sizeFormatted).toBe("1.00 MB") - }) -}) diff --git a/apps/base/src/tools/get-file-info.ts b/apps/base/src/tools/get-file-info.ts deleted file mode 100644 index 59937568..00000000 --- a/apps/base/src/tools/get-file-info.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { existsSync, statSync } from "node:fs" -import { basename, dirname, extname, resolve } from "node:path" -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import mime from "mime-types" -import { dedent } from "ts-dedent" -import { z } from "zod/v4" -import { validatePath } from "../lib/path.js" -import { errorToolResult, successToolResult } from "../lib/tool-result.js" -export async function getFileInfo(input: { path: string }) { - const { path } = input - const validatedPath = await validatePath(path) - if (!existsSync(validatedPath)) { - throw new Error(`File or directory ${path} does not exist`) - } - const stats = statSync(validatedPath) - const isDirectory = stats.isDirectory() - const mimeType = isDirectory ? null : mime.lookup(validatedPath) || "application/octet-stream" - const formatSize = (bytes: number): string => { - if (bytes === 0) return "0 B" - const units = ["B", "KB", "MB", "GB", "TB"] - const i = Math.floor(Math.log(bytes) / Math.log(1024)) - return `${(bytes / 1024 ** i).toFixed(2)} ${units[i]}` - } - return { - exists: true, - path: validatedPath, - absolutePath: resolve(validatedPath), - name: basename(validatedPath), - directory: dirname(validatedPath), - extension: isDirectory ? null : extname(validatedPath), - type: isDirectory ? "directory" : "file", - mimeType, - size: stats.size, - sizeFormatted: formatSize(stats.size), - created: stats.birthtime.toISOString(), - modified: stats.mtime.toISOString(), - accessed: stats.atime.toISOString(), - permissions: { - readable: true, - writable: Boolean(stats.mode & 0o200), - executable: Boolean(stats.mode & 0o100), - }, - } -} - -export function registerGetFileInfo(server: McpServer) { - server.registerTool( - "getFileInfo", - { - title: "Get file info", - description: dedent` - File information retriever for detailed metadata about files and directories. - - Use cases: - - Checking file existence and type - - Getting file size and timestamps - - Determining MIME types - - Validating file accessibility - - How it works: - - Retrieves comprehensive file system metadata - - Detects MIME type from file extension - - Provides both absolute and relative paths - - Returns human-readable file sizes - - Parameters: - - path: File or directory path to inspect - `, - inputSchema: { - path: z.string(), - }, - }, - async (input: { path: string }) => { - try { - return successToolResult(await getFileInfo(input)) - } catch (e) { - if (e instanceof Error) return errorToolResult(e) - throw e - } - }, - ) -} diff --git a/apps/base/src/tools/health-check.test.ts b/apps/base/src/tools/health-check.test.ts deleted file mode 100644 index ecfae09a..00000000 --- a/apps/base/src/tools/health-check.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest" -import { healthCheck } from "./health-check.js" - -describe("@perstack/base: healthCheck", () => { - it("returns health status", async () => { - const result = await healthCheck() - expect(result.status).toBe("ok") - expect(result.workspace).toBeDefined() - expect(result.uptime).toMatch(/^\d+s$/) - expect(result.memory.heapUsed).toMatch(/^\d+MB$/) - expect(result.memory.heapTotal).toMatch(/^\d+MB$/) - expect(typeof result.pid).toBe("number") - }) -}) diff --git a/apps/base/src/tools/health-check.ts b/apps/base/src/tools/health-check.ts deleted file mode 100644 index 45766e42..00000000 --- a/apps/base/src/tools/health-check.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" -import { workspacePath } from "../lib/path.js" -import { successToolResult } from "../lib/tool-result.js" - -const startTime = Date.now() -export async function healthCheck() { - const uptime = Math.floor((Date.now() - startTime) / 1000) - const memoryUsage = process.memoryUsage() - return { - status: "ok", - workspace: workspacePath, - uptime: `${uptime}s`, - memory: { - heapUsed: `${Math.round(memoryUsage.heapUsed / 1024 / 1024)}MB`, - heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`, - }, - pid: process.pid, - } -} - -export function registerHealthCheck(server: McpServer) { - server.registerTool( - "healthCheck", - { - title: "Perstack Runtime Health Check", - description: dedent` - Returns Perstack runtime health status and diagnostics. - Use cases: - - Verify Perstack runtime is running and responsive - - Check workspace configuration - - Monitor runtime uptime and memory usage - How it works: - - Returns runtime status, workspace path, uptime, and memory usage - - Always returns "ok" status if runtime can respond - - Useful for debugging connection issues - Notes: - - This is a diagnostic tool for Perstack runtime itself - - Does not access or modify files - `, - inputSchema: {}, - }, - async () => successToolResult(await healthCheck()), - ) -} diff --git a/apps/base/src/tools/list-directory.test.ts b/apps/base/src/tools/list-directory.test.ts deleted file mode 100644 index 09464ca6..00000000 --- a/apps/base/src/tools/list-directory.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import fs from "node:fs/promises" -import { join } from "node:path" -import { afterEach, describe, expect, it } from "vitest" -import { listDirectory } from "./list-directory.js" - -const testDir = "list-directory-test" -describe("listDirectory tool", () => { - afterEach(async () => { - await fs.rm(testDir, { recursive: true, force: true }) - await fs.rm("file.txt", { force: true }) - }) - - it("lists files and directories in test directory", async () => { - await fs.mkdir(testDir) - await fs.writeFile(join(testDir, "file1.txt"), "content1") - await fs.writeFile(join(testDir, "file2.txt"), "content2") - await fs.mkdir(join(testDir, "subdir")) - const items = await listDirectory({ path: testDir }) - expect(items.items).toHaveLength(3) - const names = items.items.map((item) => item.name).sort() - expect(names).toEqual(["file1.txt", "file2.txt", "subdir"]) - const file1 = items.items.find((item) => item.name === "file1.txt")! - expect(file1.type).toBe("file") - expect(file1.size).toBe(8) - const subdir = items.items.find((item) => item.name === "subdir")! - expect(subdir.type).toBe("directory") - }) - - it("lists specific directory contents", async () => { - await fs.mkdir(testDir) - await fs.writeFile(join(testDir, "nested.txt"), "nested content") - const items = await listDirectory({ path: testDir }) - expect(items.items).toHaveLength(1) - expect(items.items[0].name).toBe("nested.txt") - expect(items.items[0].type).toBe("file") - }) - - it("handles empty directory", async () => { - await fs.mkdir(testDir) - const items = await listDirectory({ path: testDir }) - expect(items.items).toHaveLength(0) - }) - - it("throws error if directory does not exist", async () => { - await expect(listDirectory({ path: "nonexistent" })).rejects.toThrow("does not exist") - }) - - it("throws error if path is not a directory", async () => { - await fs.writeFile("file.txt", "content") - await expect(listDirectory({ path: "file.txt" })).rejects.toThrow("is not a directory") - await fs.rm("file.txt") - }) -}) diff --git a/apps/base/src/tools/list-directory.ts b/apps/base/src/tools/list-directory.ts deleted file mode 100644 index 977a8325..00000000 --- a/apps/base/src/tools/list-directory.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { existsSync, statSync } from "node:fs" -import { readdir } from "node:fs/promises" -import { join } from "node:path" -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" -import { z } from "zod/v4" -import { validatePath } from "../lib/path.js" -import { errorToolResult, successToolResult } from "../lib/tool-result.js" - -interface DirectoryItem { - name: string - path: string - type: "directory" | "file" - size: number - modified: string -} -export async function listDirectory(input: { path: string }) { - const { path } = input - const validatedPath = await validatePath(path) - if (!existsSync(validatedPath)) { - throw new Error(`Directory ${path} does not exist.`) - } - const stats = statSync(validatedPath) - if (!stats.isDirectory()) { - throw new Error(`Path ${path} is not a directory.`) - } - const entries = await readdir(validatedPath) - const items: DirectoryItem[] = [] - for (const entry of entries.sort()) { - try { - const fullPath = await validatePath(join(validatedPath, entry)) - const entryStats = statSync(fullPath) - const item: DirectoryItem = { - name: entry, - path: entry, - type: entryStats.isDirectory() ? "directory" : "file", - size: entryStats.size, - modified: entryStats.mtime.toISOString(), - } - items.push(item) - } catch (e) { - if (e instanceof Error && e.message.includes("perstack directory is not allowed")) { - continue - } - throw e - } - } - return { - path: validatedPath, - items, - } -} - -export function registerListDirectory(server: McpServer) { - server.registerTool( - "listDirectory", - { - title: "List directory", - description: dedent` - Directory content lister with detailed file information. - - Use cases: - - Exploring project structure - - Finding files in a directory - - Checking directory contents before operations - - Understanding file organization - - How it works: - - Lists all files and subdirectories in specified directory only - - Provides file type, size, and modification time - - Sorts entries alphabetically - - Handles empty directories - - Parameters: - - path: Directory path to list (optional, defaults to workspace root) - `, - inputSchema: { - path: z.string(), - }, - }, - async (input: { path: string }) => { - try { - return successToolResult(await listDirectory(input)) - } catch (e) { - if (e instanceof Error) return errorToolResult(e) - throw e - } - }, - ) -} diff --git a/apps/base/src/tools/move-file.test.ts b/apps/base/src/tools/move-file.test.ts deleted file mode 100644 index f498f0e1..00000000 --- a/apps/base/src/tools/move-file.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { existsSync } from "node:fs" -import fs from "node:fs/promises" -import { join } from "node:path" -import { afterEach, describe, expect, it, vi } from "vitest" -import { moveFile } from "./move-file.js" - -const sourceFile = "move-file-source.txt" -const destFile = "move-file-destination.txt" -vi.mock("../lib/path.js", () => ({ - workspacePath: "/workspace", - validatePath: vi.fn(), -})) -const mockPath = vi.mocked(await import("../lib/path.js")) -describe("moveFile tool", () => { - afterEach(async () => { - await fs.rm(sourceFile, { force: true }) - await fs.rm(destFile, { force: true }) - await fs.rm("subdir", { recursive: true, force: true }) - await fs.rm("newdir", { recursive: true, force: true }) - vi.clearAllMocks() - }) - - it("moves file to new location successfully", async () => { - await fs.writeFile(sourceFile, "test content") - mockPath.validatePath.mockResolvedValueOnce(sourceFile).mockResolvedValueOnce(destFile) - const result = await moveFile({ source: sourceFile, destination: destFile }) - expect(existsSync(sourceFile)).toBe(false) - expect(existsSync(destFile)).toBe(true) - expect(await fs.readFile(destFile, "utf-8")).toBe("test content") - expect(result).toStrictEqual({ source: sourceFile, destination: destFile }) - }) - - it("moves file to different directory", async () => { - await fs.writeFile(sourceFile, "test content") - await fs.mkdir("subdir") - const destPath = join("subdir", destFile) - mockPath.validatePath.mockResolvedValueOnce(sourceFile).mockResolvedValueOnce(destPath) - const result = await moveFile({ source: sourceFile, destination: destPath }) - expect(existsSync(sourceFile)).toBe(false) - expect(existsSync(destPath)).toBe(true) - expect(result).toStrictEqual({ source: sourceFile, destination: destPath }) - }) - - it("creates destination directory if needed", async () => { - await fs.writeFile(sourceFile, "test content") - const destPath = join("newdir", destFile) - mockPath.validatePath.mockResolvedValueOnce(sourceFile).mockResolvedValueOnce(destPath) - const result = await moveFile({ source: sourceFile, destination: destPath }) - expect(existsSync(sourceFile)).toBe(false) - expect(existsSync(destPath)).toBe(true) - expect(existsSync("newdir")).toBe(true) - expect(result).toStrictEqual({ source: sourceFile, destination: destPath }) - }) - - it("throws error if source does not exist", async () => { - mockPath.validatePath.mockResolvedValueOnce("nonexistent.txt").mockResolvedValueOnce(destFile) - await expect(moveFile({ source: "nonexistent.txt", destination: destFile })).rejects.toThrow( - "does not exist", - ) - }) - - it("throws error if destination already exists", async () => { - await fs.writeFile(sourceFile, "source content") - await fs.writeFile(destFile, "existing content") - mockPath.validatePath.mockResolvedValueOnce(sourceFile).mockResolvedValueOnce(destFile) - await expect(moveFile({ source: sourceFile, destination: destFile })).rejects.toThrow( - "already exists", - ) - }) - - it("throws error if source is not writable", async () => { - await fs.writeFile(sourceFile, "readonly content") - await fs.chmod(sourceFile, 0o444) - mockPath.validatePath.mockResolvedValueOnce(sourceFile).mockResolvedValueOnce(destFile) - await expect(moveFile({ source: sourceFile, destination: destFile })).rejects.toThrow( - "not writable", - ) - await fs.chmod(sourceFile, 0o644) - }) -}) diff --git a/apps/base/src/tools/move-file.ts b/apps/base/src/tools/move-file.ts deleted file mode 100644 index 22603bcc..00000000 --- a/apps/base/src/tools/move-file.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { existsSync, statSync } from "node:fs" -import { mkdir, rename } from "node:fs/promises" -import { dirname } from "node:path" -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" -import { z } from "zod/v4" -import { validatePath } from "../lib/path.js" -import { errorToolResult, successToolResult } from "../lib/tool-result.js" -export async function moveFile(input: { source: string; destination: string }) { - const { source, destination } = input - const validatedSource = await validatePath(source) - const validatedDestination = await validatePath(destination) - if (!existsSync(validatedSource)) { - throw new Error(`Source file ${source} does not exist.`) - } - const sourceStats = statSync(validatedSource) - if (!(sourceStats.mode & 0o200)) { - throw new Error(`Source file ${source} is not writable`) - } - if (existsSync(validatedDestination)) { - throw new Error(`Destination ${destination} already exists.`) - } - const destDir = dirname(validatedDestination) - await mkdir(destDir, { recursive: true }) - await rename(validatedSource, validatedDestination) - return { - source: validatedSource, - destination: validatedDestination, - } -} - -export function registerMoveFile(server: McpServer) { - server.registerTool( - "moveFile", - { - title: "Move file", - description: dedent` - File mover for relocating or renaming files within the workspace. - - Use cases: - - Renaming files to follow naming conventions - - Moving files to different directories - - Organizing project structure - - Backing up files before modifications - - How it works: - - Validates source file existence - - Creates destination directory if needed - - Performs atomic move operation - - Preserves file permissions and timestamps - - Parameters: - - source: Current file path - - destination: Target file path - `, - inputSchema: { - source: z.string(), - destination: z.string(), - }, - }, - async (input: { source: string; destination: string }) => { - try { - return successToolResult(await moveFile(input)) - } catch (e) { - if (e instanceof Error) return errorToolResult(e) - throw e - } - }, - ) -} diff --git a/apps/base/src/tools/read-image-file.ts b/apps/base/src/tools/read-image-file.ts index 926a47f7..b51bd147 100644 --- a/apps/base/src/tools/read-image-file.ts +++ b/apps/base/src/tools/read-image-file.ts @@ -2,7 +2,6 @@ import { existsSync } from "node:fs" import { stat } from "node:fs/promises" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import mime from "mime-types" -import { dedent } from "ts-dedent" import { z } from "zod/v4" import { validatePath } from "../lib/path.js" import { errorToolResult, successToolResult } from "../lib/tool-result.js" @@ -38,29 +37,7 @@ export function registerReadImageFile(server: McpServer) { "readImageFile", { title: "Read image file", - description: dedent` - Image file reader that converts images to base64 encoded strings with MIME type validation. - - Use cases: - - Loading images for LLM to process - - Retrieving image data for analysis or display - - Converting workspace image files to base64 format - - How it works: - - Validates file existence and MIME type before reading - - Encodes file content as base64 string - - Returns image data with correct MIME type for proper handling - - Rejects unsupported formats with clear error messages - - Supported formats: - - PNG (image/png) - - JPEG/JPG (image/jpeg) - - GIF (image/gif) - static only, animated not supported - - WebP (image/webp) - - Notes: - - Maximum file size: 15MB (larger files will be rejected) - `, + description: "Read an image file as base64. Supports PNG, JPEG, GIF, WebP. Max 15MB.", inputSchema: { path: z.string(), }, diff --git a/apps/base/src/tools/read-pdf-file.ts b/apps/base/src/tools/read-pdf-file.ts index c300efd1..add2a4a2 100644 --- a/apps/base/src/tools/read-pdf-file.ts +++ b/apps/base/src/tools/read-pdf-file.ts @@ -2,7 +2,6 @@ import { existsSync } from "node:fs" import { stat } from "node:fs/promises" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import mime from "mime-types" -import { dedent } from "ts-dedent" import { z } from "zod/v4" import { validatePath } from "../lib/path.js" import { errorToolResult, successToolResult } from "../lib/tool-result.js" @@ -38,25 +37,7 @@ export function registerReadPdfFile(server: McpServer) { "readPdfFile", { title: "Read PDF file", - description: dedent` - PDF file reader that converts documents to base64 encoded resources. - - Use cases: - - Extracting content from PDF documents for analysis - - Loading PDF files for LLM processing - - Retrieving PDF data for conversion or manipulation - - How it works: - - Validates file existence and MIME type (application/pdf) - - Encodes PDF content as base64 blob - - Returns as resource type with proper MIME type and URI - - Rejects non-PDF files with clear error messages - - Notes: - - Returns entire PDF content, no page range support - - Maximum file size: 30MB (larger files will be rejected) - - Text extraction not performed, returns raw PDF data - `, + description: "Read a PDF file as base64. Max 30MB.", inputSchema: { path: z.string(), }, diff --git a/apps/base/src/tools/read-text-file.ts b/apps/base/src/tools/read-text-file.ts index f7d1010e..163bbfec 100644 --- a/apps/base/src/tools/read-text-file.ts +++ b/apps/base/src/tools/read-text-file.ts @@ -1,6 +1,5 @@ import { stat } from "node:fs/promises" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" import { z } from "zod/v4" import { validatePath } from "../lib/path.js" import { safeReadFile } from "../lib/safe-file.js" @@ -32,27 +31,7 @@ export function registerReadTextFile(server: McpServer) { "readTextFile", { title: "Read text file", - description: dedent` - Text file reader with line range support for UTF-8 encoded files. - - Use cases: - - Reading source code files for analysis - - Extracting specific sections from large text files - - Loading configuration or documentation files - - Viewing log files or data files - - How it works: - - Reads files as UTF-8 encoded text without format validation - - Supports partial file reading via line number ranges - - Returns content wrapped in JSON with metadata - - WARNING: Binary files will cause errors or corrupted output - - Common file types: - - Source code: .ts, .js, .py, .java, .cpp, etc. - - Documentation: .md, .txt, .rst - - Configuration: .json, .yaml, .toml, .ini - - Data files: .csv, .log, .sql - `, + description: "Read a UTF-8 text file. Supports partial reading via line ranges.", inputSchema: { path: z.string(), from: z.number().optional().describe("The line number to start reading from."), diff --git a/apps/base/src/tools/todo.ts b/apps/base/src/tools/todo.ts index 4333a621..5b8c8c1e 100644 --- a/apps/base/src/tools/todo.ts +++ b/apps/base/src/tools/todo.ts @@ -1,5 +1,4 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" import { z } from "zod/v4" import { errorToolResult, successToolResult } from "../lib/tool-result.js" @@ -47,23 +46,7 @@ export function registerTodo(server: McpServer) { "todo", { title: "todo", - description: dedent` - Todo list manager that tracks tasks and their completion status. - - Use cases: - - Creating new tasks or action items - - Marking tasks as completed - - Viewing current task list and status - - How it works: - - Each todo gets a unique ID when created - - Returns the full todo list after every operation - - Maintains state across multiple calls - - Parameters: - - newTodos: Array of task descriptions to add - - completedTodos: Array of todo IDs to mark as completed - `, + description: "Manage a todo list: add tasks and mark them completed.", inputSchema: { newTodos: z.array(z.string()).describe("New todos to add").optional(), completedTodos: z.array(z.number()).describe("Todo ids that are completed").optional(), @@ -85,18 +68,7 @@ export function registerClearTodo(server: McpServer) { "clearTodo", { title: "clearTodo", - description: dedent` - Clears the todo list. - - Use cases: - - Resetting the todo list to an empty state - - Starting fresh with a new task list - - Clearing all tasks for a new day or project - - How it works: - - Resets the todo list to an empty state - - Returns an empty todo list - `, + description: "Clear all todos.", inputSchema: {}, }, async () => { diff --git a/apps/base/src/tools/write-text-file.ts b/apps/base/src/tools/write-text-file.ts index 4be3e9a6..e405c646 100644 --- a/apps/base/src/tools/write-text-file.ts +++ b/apps/base/src/tools/write-text-file.ts @@ -1,7 +1,6 @@ import { mkdir, stat } from "node:fs/promises" import { dirname } from "node:path" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { dedent } from "ts-dedent" import { z } from "zod/v4" import { validatePath } from "../lib/path.js" import { safeWriteFile } from "../lib/safe-file.js" @@ -28,23 +27,7 @@ export function registerWriteTextFile(server: McpServer) { "writeTextFile", { title: "writeTextFile", - description: dedent` - Text file writer that creates or overwrites files with UTF-8 content. - - Use cases: - - Creating new configuration files - - Writing generated code or documentation - - Saving processed data or results - - Creating log files or reports - - How it works: - - Writes content as UTF-8 encoded text - - Returns success status with file path - - Rules: - - IF THE FILE ALREADY EXISTS, IT WILL BE OVERWRITTEN - - YOU MUST PROVIDE A VALID UTF-8 STRING FOR THE TEXT - `, + description: "Create or overwrite a UTF-8 text file. Creates parent directories as needed.", inputSchema: { path: z.string().describe("Target file path (relative or absolute)."), text: z.string().describe("Text to write to the file."), diff --git a/benchmarks/base-transport/bundled.toml b/benchmarks/base-transport/bundled.toml index 325c0c19..7ef081ec 100644 --- a/benchmarks/base-transport/bundled.toml +++ b/benchmarks/base-transport/bundled.toml @@ -9,10 +9,10 @@ providerName = "anthropic" [experts."bundled-base-benchmark"] version = "1.0.0" description = "Benchmark Expert using bundled base with InMemoryTransport" -instruction = "You are a benchmark Expert. Call healthCheck once and respond with 'Ready'." +instruction = "You are a benchmark Expert. Call readTextFile on 'perstack.toml' once and respond with 'Ready'." [experts."bundled-base-benchmark".skills."@perstack/base"] type = "mcpStdioSkill" command = "npx" packageName = "@perstack/base" -pick = ["healthCheck", "attemptCompletion"] +pick = ["readTextFile", "attemptCompletion"] diff --git a/benchmarks/base-transport/versioned.toml b/benchmarks/base-transport/versioned.toml index 451a3596..c5ffa32e 100644 --- a/benchmarks/base-transport/versioned.toml +++ b/benchmarks/base-transport/versioned.toml @@ -9,11 +9,11 @@ providerName = "anthropic" [experts."versioned-base-benchmark"] version = "1.0.0" description = "Benchmark Expert using versioned base with StdioTransport" -instruction = "You are a benchmark Expert. Call healthCheck once and respond with 'Ready'." +instruction = "You are a benchmark Expert. Call readTextFile on 'perstack.toml' once and respond with 'Ready'." [experts."versioned-base-benchmark".skills."@perstack/base"] type = "mcpStdioSkill" command = "npx" # Explicit version triggers StdioTransport fallback packageName = "@perstack/base@0.0.34" -pick = ["healthCheck", "attemptCompletion"] +pick = ["readTextFile", "attemptCompletion"] diff --git a/docs/making-experts/base-skill.md b/docs/making-experts/base-skill.md index 781004fa..41fdcd10 100644 --- a/docs/making-experts/base-skill.md +++ b/docs/making-experts/base-skill.md @@ -51,50 +51,17 @@ All file operations are restricted to the workspace directory (where `perstack r | [`attemptCompletion`](#attemptcompletion) | Runtime | Signal task completion | | [`todo`](#todo) | Runtime | Manage task list | | [`clearTodo`](#cleartodo) | Runtime | Clear task list | -| [`healthCheck`](#healthcheck) | System | Check MCP server health | | [`exec`](#exec) | System | Execute system commands | | [`readTextFile`](#readtextfile) | File | Read text files | | [`readImageFile`](#readimagefile) | File | Read image files (PNG, JPEG, GIF, WebP) | | [`readPdfFile`](#readpdffile) | File | Read PDF files | | [`writeTextFile`](#writetextfile) | File | Create or overwrite text files | -| [`appendTextFile`](#appendtextfile) | File | Append to text files | | [`editTextFile`](#edittextfile) | File | Search and replace in text files | -| [`moveFile`](#movefile) | File | Move or rename files | -| [`deleteFile`](#deletefile) | File | Delete files | -| [`getFileInfo`](#getfileinfo) | File | Get file/directory metadata | -| [`listDirectory`](#listdirectory) | Directory | List directory contents | -| [`createDirectory`](#createdirectory) | Directory | Create directories | -| [`deleteDirectory`](#deletedirectory) | Directory | Delete directories | --- ## System Execution -### healthCheck - -Returns Perstack runtime health status and diagnostics. - -**Parameters:** None - -**Returns:** -```json -{ - "status": "ok", - "workspace": "/path/to/workspace", - "uptime": "42s", - "memory": { "heapUsed": "25MB", "heapTotal": "50MB" }, - "pid": 12345 -} -``` - -**Use cases:** -- Verify Perstack runtime is running and responsive -- Check workspace configuration -- Monitor runtime uptime and memory usage -- Debug connection issues - ---- - ### exec Executes system commands within the workspace. @@ -317,33 +284,6 @@ Creates or overwrites text files. **Constraints:** - Maximum 10,000 characters per call -- Use `appendTextFile` for larger files - ---- - -### appendTextFile - -Appends content to existing files. - -**Parameters:** -| Name | Type | Required | Description | -| ------ | ------ | -------- | ---------------------------------------- | -| `path` | string | Yes | Target file path | -| `text` | string | Yes | Content to append (max 2,000 characters) | - -**Returns:** -```json -{ "path": "/workspace/file.txt", "text": "appended content" } -``` - -**Behavior:** -- Appends to end of file without modifying existing content -- Does not add newline automatically - -**Constraints:** -- File must exist -- Maximum 2,000 characters per call -- Call multiple times for larger content --- @@ -375,197 +315,22 @@ Performs search-and-replace in text files. --- -### moveFile - -Moves or renames files. - -**Parameters:** -| Name | Type | Required | Description | -| ------------- | ------ | -------- | ----------------- | -| `source` | string | Yes | Current file path | -| `destination` | string | Yes | Target file path | - -**Returns:** -```json -{ "source": "/workspace/old.txt", "destination": "/workspace/new.txt" } -``` - -**Behavior:** -- Creates destination directory if needed -- Performs atomic move operation - -**Constraints:** -- Source must exist -- Destination must not exist -- Source must be writable - ---- - -### deleteFile - -Removes files from the workspace. - -**Parameters:** -| Name | Type | Required | Description | -| ------ | ------ | -------- | ------------------- | -| `path` | string | Yes | File path to delete | - -**Returns:** -```json -{ "path": "/workspace/deleted.txt" } -``` - -**Constraints:** -- File must exist -- File must be writable -- Cannot delete directories (use directory-specific tools) - ---- - -### getFileInfo - -Retrieves detailed file or directory metadata. - -**Parameters:** -| Name | Type | Required | Description | -| ------ | ------ | -------- | ---------------------- | -| `path` | string | Yes | File or directory path | - -**Returns:** -```json -{ - "exists": true, - "path": "file.txt", - "absolutePath": "/workspace/file.txt", - "name": "file.txt", - "directory": "/workspace", - "extension": ".txt", - "type": "file", - "mimeType": "text/plain", - "size": 1234, - "sizeFormatted": "1.21 KB", - "created": "2024-01-01T00:00:00.000Z", - "modified": "2024-01-02T00:00:00.000Z", - "accessed": "2024-01-03T00:00:00.000Z", - "permissions": { - "readable": true, - "writable": true, - "executable": false - } -} -``` - -**Behavior:** -- Works for both files and directories -- Returns `null` for extension and mimeType on directories -- Formats size in human-readable format (B, KB, MB, GB, TB) - ---- - -## Directory Operations - -### listDirectory - -Lists directory contents with metadata. - -**Parameters:** -| Name | Type | Required | Description | -| ------ | ------ | -------- | -------------- | -| `path` | string | Yes | Directory path | - -**Returns:** -```json -{ - "path": "/workspace/src", - "items": [ - { "name": "index.ts", "path": "index.ts", "type": "file", "size": 256, "modified": "2024-01-01T00:00:00.000Z" }, - { "name": "lib", "path": "lib", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00.000Z" } - ] -} -``` - -**Behavior:** -- Lists only immediate children (non-recursive) -- Sorts entries alphabetically -- Excludes `perstack/` directory from results - -**Constraints:** -- Path must exist -- Path must be a directory - ---- - -### createDirectory - -Creates directories with recursive parent creation. - -**Parameters:** -| Name | Type | Required | Description | -| ------ | ------ | -------- | ------------------------ | -| `path` | string | Yes | Directory path to create | - -**Returns:** -```json -{ "path": "/workspace/new/nested/dir" } -``` - -**Behavior:** -- Creates all parent directories as needed -- Uses `mkdir` with `recursive: true` - -**Constraints:** -- Directory must not already exist -- Parent directory must be writable - ---- - -### deleteDirectory - -Removes directories from the workspace. - -**Parameters:** -| Name | Type | Required | Description | -| ----------- | --------- | -------- | -------------------------------------- | -| `path` | `string` | Yes | Directory path to delete | -| `recursive` | `boolean` | No | Whether to delete contents recursively | - -**Returns:** -```json -{ "path": "/workspace/old-dir" } -``` - -**Behavior:** -- Validates directory existence and permissions -- Removes directory (and contents if `recursive: true`) - -**Constraints:** -- Directory must exist -- Directory must be writable -- Non-empty directories require `recursive: true` -- Cannot delete files (use `deleteFile` instead) - ---- - ## Example: Agent Loop Flow Here's how an Expert uses Base Skill tools in a typical agent loop: ``` ┌─────────────────────────────────────────────────────────────────┐ -│ User: "Organize files in this directory by type" │ +│ User: "Read all source files and write a summary" │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 1. LIST DIRECTORY │ +│ 1. LIST FILES │ │ ───────────────────────────────────────────────────────────── │ -│ tool: listDirectory │ -│ input: { path: "." } │ -│ output: { items: [ │ -│ { name: "photo.jpg", type: "file" }, │ -│ { name: "report.pdf", type: "file" }, │ -│ { name: "notes.txt", type: "file" } │ -│ ]} │ +│ tool: exec │ +│ input: { command: "ls", args: ["src/"], ... } │ +│ output: { output: "index.ts\nlib/\nutils/" } │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -574,37 +339,28 @@ Here's how an Expert uses Base Skill tools in a typical agent loop: │ ───────────────────────────────────────────────────────────── │ │ tool: todo │ │ input: { newTodos: [ │ -│ "Create images/ directory", │ -│ "Create documents/ directory", │ -│ "Move files to appropriate directories" │ +│ "Read source files", │ +│ "Write summary" │ │ ]} │ │ output: { todos: [{ id: 0, title: "...", completed: false }] } │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 3. CREATE DIRECTORIES │ +│ 3. READ FILES │ │ ───────────────────────────────────────────────────────────── │ -│ tool: createDirectory │ -│ input: { path: "images" } │ -│ output: { path: "/workspace/images" } │ -│ │ -│ tool: createDirectory │ -│ input: { path: "documents" } │ -│ output: { path: "/workspace/documents" } │ +│ tool: readTextFile │ +│ input: { path: "src/index.ts" } │ +│ output: { content: "..." } │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 4. MOVE FILES │ +│ 4. WRITE SUMMARY │ │ ───────────────────────────────────────────────────────────── │ -│ tool: moveFile │ -│ input: { source: "photo.jpg", destination: "images/photo.jpg" }│ -│ output: { source: "...", destination: "..." } │ -│ │ -│ tool: moveFile │ -│ input: { source: "report.pdf", destination: "documents/..." } │ -│ ... │ +│ tool: writeTextFile │ +│ input: { path: "SUMMARY.md", text: "# Summary\n..." } │ +│ output: { path: "/workspace/SUMMARY.md" } │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -612,7 +368,7 @@ Here's how an Expert uses Base Skill tools in a typical agent loop: │ 5. MARK TODOS COMPLETE │ │ ───────────────────────────────────────────────────────────── │ │ tool: todo │ -│ input: { completedTodos: [0, 1, 2] } │ +│ input: { completedTodos: [0, 1] } │ │ output: { todos: [{ id: 0, completed: true }, ...] } │ └─────────────────────────────────────────────────────────────────┘ │ @@ -630,13 +386,13 @@ Here's how an Expert uses Base Skill tools in a typical agent loop: The Expert definition for this workflow: ```toml -[experts."file-organizer"] -description = "Organizes files in the workspace by type" +[experts."summarizer"] +description = "Reads source files and writes a summary" instruction = """ -You organize files in the current directory. -1. List all files using listDirectory -2. Create subdirectories by file type (images/, documents/, etc.) -3. Move files to appropriate directories using moveFile +You read all source files and write a summary. +1. List files using exec +2. Read each source file using readTextFile +3. Write a summary using writeTextFile """ ``` diff --git a/docs/operating-experts/skill-management.md b/docs/operating-experts/skill-management.md index b81670c1..ab536b6a 100644 --- a/docs/operating-experts/skill-management.md +++ b/docs/operating-experts/skill-management.md @@ -87,8 +87,8 @@ Control which tools are available to the Expert: type = "mcpStdioSkill" command = "npx" packageName = "@perstack/base" -pick = ["readFile", "writeFile"] # Only these tools -omit = ["deleteFile"] # Exclude these tools +pick = ["readTextFile", "writeTextFile"] # Only these tools +omit = ["exec"] # Exclude these tools ``` ### Environment Variables diff --git a/docs/references/events.md b/docs/references/events.md index 99211ce6..f5ef9890 100644 --- a/docs/references/events.md +++ b/docs/references/events.md @@ -275,18 +275,6 @@ type Activity = { | `readPdfFile` | Read a PDF file | | `writeTextFile` | Write/create a text file | | `editTextFile` | Edit existing file content | -| `appendTextFile` | Append to a file | -| `deleteFile` | Delete a file | -| `moveFile` | Move/rename a file | -| `getFileInfo` | Get file metadata | - -#### Directory Operations - -| Type | Description | -| ----------------- | ----------------------- | -| `listDirectory` | List directory contents | -| `createDirectory` | Create a directory | -| `deleteDirectory` | Delete a directory | #### Execution diff --git a/e2e/experts/bundled-base.toml b/e2e/experts/bundled-base.toml index d4cf2336..e783d0be 100644 --- a/e2e/experts/bundled-base.toml +++ b/e2e/experts/bundled-base.toml @@ -13,11 +13,11 @@ envPath = [".env", ".env.local"] version = "1.0.0" description = "E2E test expert for bundled base with InMemoryTransport" instruction = """ -Call healthCheck and then call attemptCompletion with result "OK" +Call readTextFile on "perstack.toml" and then call attemptCompletion with result "OK" """ [experts."e2e-bundled-base".skills."@perstack/base"] type = "mcpStdioSkill" command = "npx" packageName = "@perstack/base" -pick = ["attemptCompletion", "healthCheck"] +pick = ["attemptCompletion", "readTextFile"] diff --git a/e2e/experts/lockfile.toml b/e2e/experts/lockfile.toml index 28bd6f85..67018a51 100644 --- a/e2e/experts/lockfile.toml +++ b/e2e/experts/lockfile.toml @@ -12,11 +12,11 @@ envPath = [".env", ".env.local"] version = "1.0.0" description = "E2E test expert for lockfile functionality" instruction = """ -Call healthCheck and then call attemptCompletion with result "lockfile-test-ok" +Call readTextFile on "perstack.toml" and then call attemptCompletion with result "lockfile-test-ok" """ [experts."e2e-lockfile".skills."@perstack/base"] type = "mcpStdioSkill" command = "npx" packageName = "@perstack/base" -pick = ["attemptCompletion", "healthCheck"] +pick = ["attemptCompletion", "readTextFile"] diff --git a/e2e/experts/versioned-base.toml b/e2e/experts/versioned-base.toml index 9ff3ce3a..23f4c90a 100644 --- a/e2e/experts/versioned-base.toml +++ b/e2e/experts/versioned-base.toml @@ -13,7 +13,7 @@ envPath = [".env", ".env.local"] version = "1.0.0" description = "E2E test expert for versioned base with StdioTransport" instruction = """ -Call healthCheck and then call attemptCompletion with result "OK" +Call readTextFile on "perstack.toml" and then call attemptCompletion with result "OK" """ [experts."e2e-versioned-base".skills."@perstack/base"] @@ -21,4 +21,4 @@ type = "mcpStdioSkill" command = "npx" # Explicit version triggers StdioTransport fallback packageName = "@perstack/base@0.0.34" -pick = ["attemptCompletion", "healthCheck"] +pick = ["attemptCompletion", "readTextFile"] diff --git a/e2e/perstack-cli/bundled-base.test.ts b/e2e/perstack-cli/bundled-base.test.ts index 8bb3911d..18cf5ba8 100644 --- a/e2e/perstack-cli/bundled-base.test.ts +++ b/e2e/perstack-cli/bundled-base.test.ts @@ -21,7 +21,7 @@ describe.concurrent("Bundled Base Skill", () => { "should use InMemoryTransport for bundled base (spawnDurationMs = 0)", async () => { const cmdResult = await runCli( - ["run", "--config", BUNDLED_BASE_CONFIG, "e2e-bundled-base", "Run health check"], + ["run", "--config", BUNDLED_BASE_CONFIG, "e2e-bundled-base", "Read a text file"], { timeout: LLM_TIMEOUT }, ) const result = withEventParsing(cmdResult) @@ -57,18 +57,18 @@ describe.concurrent("Bundled Base Skill", () => { "should have all base skill tools available", async () => { const cmdResult = await runCli( - ["run", "--config", BUNDLED_BASE_CONFIG, "e2e-bundled-base", "Run health check"], + ["run", "--config", BUNDLED_BASE_CONFIG, "e2e-bundled-base", "Read a text file"], { timeout: LLM_TIMEOUT }, ) const result = withEventParsing(cmdResult) expect(result.exitCode).toBe(0) - // Check that healthCheck was called (from pick list) + // Check that readTextFile was called (from pick list) const callToolsEvents = filterEventsByType(result.events, "callTools") const hasHealthCheck = callToolsEvents.some((e) => { const event = e as { toolCalls?: Array<{ toolName: string }> } - return event.toolCalls?.some((tc) => tc.toolName === "healthCheck") + return event.toolCalls?.some((tc) => tc.toolName === "readTextFile") }) expect(hasHealthCheck).toBe(true) }, diff --git a/e2e/perstack-cli/lockfile.test.ts b/e2e/perstack-cli/lockfile.test.ts index f8b68fe9..736c22ac 100644 --- a/e2e/perstack-cli/lockfile.test.ts +++ b/e2e/perstack-cli/lockfile.test.ts @@ -45,7 +45,7 @@ describe("Lockfile", () => { expect(lockfileContent).toContain('version = "1"') expect(lockfileContent).toContain("generatedAt") expect(lockfileContent).toContain("e2e-lockfile") - expect(lockfileContent).toContain("healthCheck") + expect(lockfileContent).toContain("readTextFile") expect(lockfileContent).toContain("attemptCompletion") }, INSTALL_TIMEOUT, diff --git a/e2e/perstack-cli/versioned-base.test.ts b/e2e/perstack-cli/versioned-base.test.ts index b2e204a5..76bdf46d 100644 --- a/e2e/perstack-cli/versioned-base.test.ts +++ b/e2e/perstack-cli/versioned-base.test.ts @@ -21,7 +21,7 @@ describe.concurrent("Versioned Base Skill (StdioTransport Fallback)", () => { "should use StdioTransport for versioned base (spawnDurationMs > 0)", async () => { const cmdResult = await runCli( - ["run", "--config", VERSIONED_BASE_CONFIG, "e2e-versioned-base", "Run health check"], + ["run", "--config", VERSIONED_BASE_CONFIG, "e2e-versioned-base", "Read a text file"], { timeout: LLM_TIMEOUT }, ) const result = withEventParsing(cmdResult) @@ -57,18 +57,18 @@ describe.concurrent("Versioned Base Skill (StdioTransport Fallback)", () => { "should have picked tools available", async () => { const cmdResult = await runCli( - ["run", "--config", VERSIONED_BASE_CONFIG, "e2e-versioned-base", "Run health check"], + ["run", "--config", VERSIONED_BASE_CONFIG, "e2e-versioned-base", "Read a text file"], { timeout: LLM_TIMEOUT }, ) const result = withEventParsing(cmdResult) expect(result.exitCode).toBe(0) - // Check that healthCheck was called (from pick list) + // Check that readTextFile was called (from pick list) const callToolsEvents = filterEventsByType(result.events, "callTools") const hasHealthCheck = callToolsEvents.some((e) => { const event = e as { toolCalls?: Array<{ toolName: string }> } - return event.toolCalls?.some((tc) => tc.toolName === "healthCheck") + return event.toolCalls?.some((tc) => tc.toolName === "readTextFile") }) expect(hasHealthCheck).toBe(true) }, diff --git a/examples/github-issue-bot/checkpoint-filter.ts b/examples/github-issue-bot/checkpoint-filter.ts index 8420a9e6..06d184fb 100644 --- a/examples/github-issue-bot/checkpoint-filter.ts +++ b/examples/github-issue-bot/checkpoint-filter.ts @@ -82,10 +82,6 @@ function formatEvent(event: Record): string | null { const path = args?.path as string if (path) return `📖 Reading: ${path}` } - if (toolName === "listDirectory") { - const path = args?.path as string - if (path) return `📁 Listing: ${path}` - } if (toolName === "exec") { const command = args?.command as string const cmdArgs = args?.args as string[] diff --git a/examples/gmail-assistant/filter.ts b/examples/gmail-assistant/filter.ts index f6fde8be..01d0777a 100644 --- a/examples/gmail-assistant/filter.ts +++ b/examples/gmail-assistant/filter.ts @@ -49,10 +49,6 @@ function formatEvent(event: Record): string | null { const path = args?.path as string if (path) return `[${expertKey}] ✏️ Editing: ${path}` } - if (toolName === "listDirectory") { - const path = args?.path as string - if (path) return `[${expertKey}] 📁 Listing: ${path}` - } if (toolName === "attemptCompletion") { return `[${expertKey}] ✨ Completing...` } diff --git a/packages/core/src/schemas/activity.ts b/packages/core/src/schemas/activity.ts index a668c7d4..c69830a5 100644 --- a/packages/core/src/schemas/activity.ts +++ b/packages/core/src/schemas/activity.ts @@ -209,22 +209,6 @@ export const editTextFileActivitySchema = baseActivitySchema.extend({ }) editTextFileActivitySchema satisfies z.ZodType -/** Append text file activity */ -export interface AppendTextFileActivity extends BaseActivity { - type: "appendTextFile" - path: string - text: string - error?: string -} - -export const appendTextFileActivitySchema = baseActivitySchema.extend({ - type: z.literal("appendTextFile"), - path: z.string(), - text: z.string(), - error: z.string().optional(), -}) -appendTextFileActivitySchema satisfies z.ZodType - /** Write text file activity */ export interface WriteTextFileActivity extends BaseActivity { type: "writeTextFile" @@ -241,140 +225,6 @@ export const writeTextFileActivitySchema = baseActivitySchema.extend({ }) writeTextFileActivitySchema satisfies z.ZodType -/** Delete file activity */ -export interface DeleteFileActivity extends BaseActivity { - type: "deleteFile" - path: string - error?: string -} - -export const deleteFileActivitySchema = baseActivitySchema.extend({ - type: z.literal("deleteFile"), - path: z.string(), - error: z.string().optional(), -}) -deleteFileActivitySchema satisfies z.ZodType - -/** Delete directory activity */ -export interface DeleteDirectoryActivity extends BaseActivity { - type: "deleteDirectory" - path: string - recursive?: boolean - error?: string -} - -export const deleteDirectoryActivitySchema = baseActivitySchema.extend({ - type: z.literal("deleteDirectory"), - path: z.string(), - recursive: z.boolean().optional(), - error: z.string().optional(), -}) -deleteDirectoryActivitySchema satisfies z.ZodType - -/** Move file activity */ -export interface MoveFileActivity extends BaseActivity { - type: "moveFile" - source: string - destination: string - error?: string -} - -export const moveFileActivitySchema = baseActivitySchema.extend({ - type: z.literal("moveFile"), - source: z.string(), - destination: z.string(), - error: z.string().optional(), -}) -moveFileActivitySchema satisfies z.ZodType - -/** Get file info activity */ -export interface GetFileInfoActivity extends BaseActivity { - type: "getFileInfo" - path: string - info?: { - exists: boolean - name: string - directory: string - extension: string | null - type: "file" | "directory" - mimeType: string | null - size: number - sizeFormatted: string - created: string - modified: string - accessed: string - } - error?: string -} - -export const getFileInfoActivitySchema = baseActivitySchema.extend({ - type: z.literal("getFileInfo"), - path: z.string(), - info: z - .object({ - exists: z.boolean(), - name: z.string(), - directory: z.string(), - extension: z.string().nullable(), - type: z.enum(["file", "directory"]), - mimeType: z.string().nullable(), - size: z.number(), - sizeFormatted: z.string(), - created: z.string(), - modified: z.string(), - accessed: z.string(), - }) - .optional(), - error: z.string().optional(), -}) -getFileInfoActivitySchema satisfies z.ZodType - -/** Create directory activity */ -export interface CreateDirectoryActivity extends BaseActivity { - type: "createDirectory" - path: string - error?: string -} - -export const createDirectoryActivitySchema = baseActivitySchema.extend({ - type: z.literal("createDirectory"), - path: z.string(), - error: z.string().optional(), -}) -createDirectoryActivitySchema satisfies z.ZodType - -/** List directory activity */ -export interface ListDirectoryActivity extends BaseActivity { - type: "listDirectory" - path: string - items?: Array<{ - name: string - path: string - type: "file" | "directory" - size: number - modified: string - }> - error?: string -} - -export const listDirectoryActivitySchema = baseActivitySchema.extend({ - type: z.literal("listDirectory"), - path: z.string(), - items: z - .array( - z.object({ - name: z.string(), - path: z.string(), - type: z.enum(["file", "directory"]), - size: z.number(), - modified: z.string(), - }), - ) - .optional(), - error: z.string().optional(), -}) -listDirectoryActivitySchema satisfies z.ZodType - /** Exec activity - Command execution */ export interface ExecActivity extends BaseActivity { type: "exec" @@ -474,14 +324,7 @@ export type Activity = | ReadPdfFileActivity | ReadTextFileActivity | EditTextFileActivity - | AppendTextFileActivity | WriteTextFileActivity - | DeleteFileActivity - | DeleteDirectoryActivity - | MoveFileActivity - | GetFileInfoActivity - | CreateDirectoryActivity - | ListDirectoryActivity | ExecActivity | DelegateActivity | DelegationCompleteActivity @@ -500,14 +343,7 @@ export const activitySchema = z.discriminatedUnion("type", [ readPdfFileActivitySchema, readTextFileActivitySchema, editTextFileActivitySchema, - appendTextFileActivitySchema, writeTextFileActivitySchema, - deleteFileActivitySchema, - deleteDirectoryActivitySchema, - moveFileActivitySchema, - getFileInfoActivitySchema, - createDirectoryActivitySchema, - listDirectoryActivitySchema, execActivitySchema, delegateActivitySchema, delegationCompleteActivitySchema, diff --git a/packages/core/src/utils/activity.test.ts b/packages/core/src/utils/activity.test.ts index e8d7e6c3..c5048fbc 100644 --- a/packages/core/src/utils/activity.test.ts +++ b/packages/core/src/utils/activity.test.ts @@ -1006,173 +1006,6 @@ describe("getActivities", () => { } }) - it("returns deleteDirectory activity", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [ - createToolCall({ - toolName: "deleteDirectory", - args: { path: "/old-dir", recursive: true }, - }), - ], - toolResults: [ - createToolResult({ - toolName: "deleteDirectory", - result: [{ type: "textPart", id: "tp-1", text: "{}" }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("deleteDirectory") - if (activities[0].type === "deleteDirectory") { - expect(activities[0].path).toBe("/old-dir") - expect(activities[0].recursive).toBe(true) - } - }) - - it("returns moveFile activity", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [ - createToolCall({ - toolName: "moveFile", - args: { source: "/old.txt", destination: "/new.txt" }, - }), - ], - toolResults: [ - createToolResult({ - toolName: "moveFile", - result: [{ type: "textPart", id: "tp-1", text: "{}" }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("moveFile") - if (activities[0].type === "moveFile") { - expect(activities[0].source).toBe("/old.txt") - expect(activities[0].destination).toBe("/new.txt") - } - }) - - it("returns getFileInfo activity", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [ - createToolCall({ - toolName: "getFileInfo", - args: { path: "/test.txt" }, - }), - ], - toolResults: [ - createToolResult({ - toolName: "getFileInfo", - result: [ - { - type: "textPart", - id: "tp-1", - text: JSON.stringify({ - exists: true, - name: "test.txt", - directory: "/", - extension: ".txt", - type: "file", - mimeType: "text/plain", - size: 100, - sizeFormatted: "100 B", - created: "2024-01-01", - modified: "2024-01-02", - accessed: "2024-01-03", - }), - }, - ], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("getFileInfo") - if (activities[0].type === "getFileInfo") { - expect(activities[0].path).toBe("/test.txt") - expect(activities[0].info?.exists).toBe(true) - expect(activities[0].info?.name).toBe("test.txt") - expect(activities[0].info?.size).toBe(100) - } - }) - - it("returns createDirectory activity", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [ - createToolCall({ - toolName: "createDirectory", - args: { path: "/new-dir" }, - }), - ], - toolResults: [ - createToolResult({ - toolName: "createDirectory", - result: [{ type: "textPart", id: "tp-1", text: "{}" }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("createDirectory") - if (activities[0].type === "createDirectory") { - expect(activities[0].path).toBe("/new-dir") - } - }) - - it("returns listDirectory activity with items", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [ - createToolCall({ - toolName: "listDirectory", - args: { path: "/workspace" }, - }), - ], - toolResults: [ - createToolResult({ - toolName: "listDirectory", - result: [ - { - type: "textPart", - id: "tp-1", - text: JSON.stringify({ - items: [ - { name: "file1.txt", path: "/workspace/file1.txt", type: "file", size: 100 }, - { name: "subdir", path: "/workspace/subdir", type: "directory", size: 0 }, - ], - }), - }, - ], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("listDirectory") - if (activities[0].type === "listDirectory") { - expect(activities[0].path).toBe("/workspace") - expect(activities[0].items).toHaveLength(2) - expect(activities[0].items?.[0].name).toBe("file1.txt") - expect(activities[0].items?.[1].type).toBe("directory") - } - }) - it("returns clearTodo activity", () => { const checkpoint = createBaseCheckpoint() const step = createBaseStep({ @@ -1196,59 +1029,6 @@ describe("getActivities", () => { expect(activities[0].type).toBe("clearTodo") }) - it("returns appendTextFile activity", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [ - createToolCall({ - toolName: "appendTextFile", - args: { path: "/log.txt", text: "new line" }, - }), - ], - toolResults: [ - createToolResult({ - toolName: "appendTextFile", - result: [{ type: "textPart", id: "tp-1", text: "{}" }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("appendTextFile") - if (activities[0].type === "appendTextFile") { - expect(activities[0].path).toBe("/log.txt") - expect(activities[0].text).toBe("new line") - } - }) - - it("returns deleteFile activity", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [ - createToolCall({ - toolName: "deleteFile", - args: { path: "/old.txt" }, - }), - ], - toolResults: [ - createToolResult({ - toolName: "deleteFile", - result: [{ type: "textPart", id: "tp-1", text: "{}" }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("deleteFile") - if (activities[0].type === "deleteFile") { - expect(activities[0].path).toBe("/old.txt") - } - }) - it("returns attemptCompletion activity with remainingTodos", () => { const checkpoint = createBaseCheckpoint() const step = createBaseStep({ @@ -1366,70 +1146,6 @@ describe("getActivities", () => { expect(activities[0].type).toBe("readTextFile") }) - it("handles missing fields in parsed JSON", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [createToolCall({ toolName: "getFileInfo", args: { path: "/test.txt" } })], - toolResults: [ - createToolResult({ - toolName: "getFileInfo", - result: [{ type: "textPart", id: "tp-1", text: '{"exists": false}' }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("getFileInfo") - if (activities[0].type === "getFileInfo") { - expect(activities[0].info?.exists).toBe(false) - expect(activities[0].info?.name).toBe("") - } - }) - - it("handles invalid JSON in listDirectory result", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [createToolCall({ toolName: "listDirectory", args: { path: "/test" } })], - toolResults: [ - createToolResult({ - toolName: "listDirectory", - result: [{ type: "textPart", id: "tp-1", text: "not json" }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("listDirectory") - if (activities[0].type === "listDirectory") { - expect(activities[0].items).toBeUndefined() - } - }) - - it("handles invalid JSON in getFileInfo result", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [createToolCall({ toolName: "getFileInfo", args: { path: "/test" } })], - toolResults: [ - createToolResult({ - toolName: "getFileInfo", - result: [{ type: "textPart", id: "tp-1", text: "not json" }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("getFileInfo") - if (activities[0].type === "getFileInfo") { - expect(activities[0].info).toBeUndefined() - } - }) - it("handles todos with missing id/title/completed fields", () => { const checkpoint = createBaseCheckpoint() const step = createBaseStep({ @@ -1503,44 +1219,6 @@ describe("getActivities", () => { } }) - it("handles listDirectory items with missing/invalid fields", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [createToolCall({ toolName: "listDirectory", args: { path: "/" } })], - toolResults: [ - createToolResult({ - toolName: "listDirectory", - result: [ - { - type: "textPart", - id: "tp-1", - text: JSON.stringify({ - items: [ - { name: "file.txt", type: "file", size: 100 }, - { name: "dir", type: "directory" }, // missing size - {}, // missing all fields - ], - }), - }, - ], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("listDirectory") - if (activities[0].type === "listDirectory") { - expect(activities[0].items).toHaveLength(3) - expect(activities[0].items?.[0].size).toBe(100) - expect(activities[0].items?.[1].type).toBe("directory") - expect(activities[0].items?.[1].size).toBe(0) // default - expect(activities[0].items?.[2].name).toBe("") - expect(activities[0].items?.[2].type).toBe("file") // default - } - }) - it("handles todo result with non-array todos field", () => { const checkpoint = createBaseCheckpoint() const step = createBaseStep({ @@ -1562,27 +1240,6 @@ describe("getActivities", () => { } }) - it("handles listDirectory result with non-array items field", () => { - const checkpoint = createBaseCheckpoint() - const step = createBaseStep({ - toolCalls: [createToolCall({ toolName: "listDirectory", args: { path: "/" } })], - toolResults: [ - createToolResult({ - toolName: "listDirectory", - result: [{ type: "textPart", id: "tp-1", text: '{"items": "not an array"}' }], - }), - ], - }) - - const activities = getActivities({ checkpoint, step }) - - expect(activities).toHaveLength(1) - expect(activities[0].type).toBe("listDirectory") - if (activities[0].type === "listDirectory") { - expect(activities[0].items).toBeUndefined() - } - }) - it("handles readImageFile with number fields", () => { const checkpoint = createBaseCheckpoint() const step = createBaseStep({ diff --git a/packages/core/src/utils/activity.ts b/packages/core/src/utils/activity.ts index 5197e6fd..fdbf4b96 100644 --- a/packages/core/src/utils/activity.ts +++ b/packages/core/src/utils/activity.ts @@ -342,15 +342,6 @@ export function createBaseToolActivity( error: errorText, } - case "appendTextFile": - return { - type: "appendTextFile", - ...baseFields, - path: String(args["path"] ?? ""), - text: String(args["text"] ?? ""), - error: errorText, - } - case "writeTextFile": return { type: "writeTextFile", @@ -360,58 +351,6 @@ export function createBaseToolActivity( error: errorText, } - case "deleteFile": - return { - type: "deleteFile", - ...baseFields, - path: String(args["path"] ?? ""), - error: errorText, - } - - case "deleteDirectory": - return { - type: "deleteDirectory", - ...baseFields, - path: String(args["path"] ?? ""), - recursive: typeof args["recursive"] === "boolean" ? args["recursive"] : undefined, - error: errorText, - } - - case "moveFile": - return { - type: "moveFile", - ...baseFields, - source: String(args["source"] ?? ""), - destination: String(args["destination"] ?? ""), - error: errorText, - } - - case "getFileInfo": - return { - type: "getFileInfo", - ...baseFields, - path: String(args["path"] ?? ""), - info: parseFileInfoFromResult(resultContents), - error: errorText, - } - - case "createDirectory": - return { - type: "createDirectory", - ...baseFields, - path: String(args["path"] ?? ""), - error: errorText, - } - - case "listDirectory": - return { - type: "listDirectory", - ...baseFields, - path: String(args["path"] ?? ""), - items: parseListDirectoryFromResult(resultContents), - error: errorText, - } - case "exec": return { type: "exec", @@ -543,74 +482,3 @@ function parseTodosFromResult( } return [] } - -function parseFileInfoFromResult(result: MessagePart[]): - | { - exists: boolean - name: string - directory: string - extension: string | null - type: "file" | "directory" - mimeType: string | null - size: number - sizeFormatted: string - created: string - modified: string - accessed: string - } - | undefined { - const textPart = result.find((p) => p.type === "textPart") - if (!textPart?.text) return undefined - try { - const parsed = JSON.parse(textPart.text) - return { - exists: typeof parsed.exists === "boolean" ? parsed.exists : true, - name: String(parsed.name ?? ""), - directory: String(parsed.directory ?? ""), - extension: typeof parsed.extension === "string" ? parsed.extension : null, - type: parsed.type === "directory" ? "directory" : "file", - mimeType: typeof parsed.mimeType === "string" ? parsed.mimeType : null, - size: typeof parsed.size === "number" ? parsed.size : 0, - sizeFormatted: String(parsed.sizeFormatted ?? ""), - created: String(parsed.created ?? ""), - modified: String(parsed.modified ?? ""), - accessed: String(parsed.accessed ?? ""), - } - } catch { - return undefined - } -} - -function parseListDirectoryFromResult(result: MessagePart[]): - | Array<{ - name: string - path: string - type: "file" | "directory" - size: number - modified: string - }> - | undefined { - const textPart = result.find((p) => p.type === "textPart") - if (!textPart?.text) return undefined - try { - const parsed = JSON.parse(textPart.text) - if (!Array.isArray(parsed.items)) return undefined - return parsed.items.map( - (item: { - name?: string - path?: string - type?: string - size?: number - modified?: string - }) => ({ - name: String(item.name ?? ""), - path: String(item.path ?? ""), - type: item.type === "directory" ? "directory" : "file", - size: typeof item.size === "number" ? item.size : 0, - modified: String(item.modified ?? ""), - }), - ) - } catch { - return undefined - } -} diff --git a/packages/runtime/src/skill-manager/in-memory-base.test.ts b/packages/runtime/src/skill-manager/in-memory-base.test.ts index 3d52b587..b19f3559 100644 --- a/packages/runtime/src/skill-manager/in-memory-base.test.ts +++ b/packages/runtime/src/skill-manager/in-memory-base.test.ts @@ -129,16 +129,16 @@ describe("@perstack/runtime: InMemoryBaseSkillManager", () => { it("throws error if not initialized", async () => { const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - await expect(manager.callTool("healthCheck", {})).rejects.toThrow( + await expect(manager.callTool("todo", {})).rejects.toThrow( "@perstack/base is not initialized", ) }) - it("can call healthCheck tool after init", async () => { + it("can call todo tool after init", async () => { const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") await manager.init() - const result = await manager.callTool("healthCheck", {}) + const result = await manager.callTool("todo", {}) expect(Array.isArray(result)).toBe(true) expect(result.length).toBeGreaterThan(0) diff --git a/packages/tui-components/src/components/checkpoint-action-row.tsx b/packages/tui-components/src/components/checkpoint-action-row.tsx index a54ccffa..02c4ef84 100644 --- a/packages/tui-components/src/components/checkpoint-action-row.tsx +++ b/packages/tui-components/src/components/checkpoint-action-row.tsx @@ -117,47 +117,6 @@ function renderAction(action: Activity): React.ReactNode { case "editTextFile": return renderEditTextFile(action, color) - case "appendTextFile": - return renderAppendTextFile(action, color) - - case "deleteFile": - return ( - - Removed - - ) - - case "deleteDirectory": - return ( - - Removed{action.recursive ? " (recursive)" : ""} - - ) - - case "moveFile": - return ( - - → {shortenPath(action.destination, 30)} - - ) - - case "createDirectory": - return ( - - Created - - ) - - case "listDirectory": - return renderListDirectory(action, color) - - case "getFileInfo": - return ( - - Fetched - - ) - case "readPdfFile": return ( @@ -372,52 +331,6 @@ function renderEditTextFile( ) } -function renderAppendTextFile( - action: Extract, - color: StatusColor, -): React.ReactNode { - const { path, text } = action - const lines = text.split("\n") - return ( - - - {lines.map((line, idx) => ( - - - + - - - {line} - - - ))} - - - ) -} - -function renderListDirectory( - action: Extract, - color: StatusColor, -): React.ReactNode { - const { path, items } = action - const itemLines = - items?.map((item) => `${item.type === "directory" ? "📁" : "📄"} ${item.name}`) ?? [] - const { visible, remaining } = summarizeOutput(itemLines, RENDER_CONSTANTS.LIST_DIR_MAX_ITEMS) - return ( - - - {visible.map((line, idx) => ( - - {truncateText(line, UI_CONSTANTS.TRUNCATE_TEXT_DEFAULT)} - - ))} - {remaining > 0 && ... +{remaining} more} - - - ) -} - function renderExec( action: Extract, color: StatusColor, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e8d56bc..e2d83f0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,9 +68,6 @@ importers: mime-types: specifier: ^3.0.2 version: 3.0.2 - ts-dedent: - specifier: ^2.2.0 - version: 2.2.0 zod: specifier: ^4.3.6 version: 4.3.6 diff --git a/scripts/checkpoint-filter.ts b/scripts/checkpoint-filter.ts index 8420a9e6..06d184fb 100644 --- a/scripts/checkpoint-filter.ts +++ b/scripts/checkpoint-filter.ts @@ -82,10 +82,6 @@ function formatEvent(event: Record): string | null { const path = args?.path as string if (path) return `📖 Reading: ${path}` } - if (toolName === "listDirectory") { - const path = args?.path as string - if (path) return `📁 Listing: ${path}` - } if (toolName === "exec") { const command = args?.command as string const cmdArgs = args?.args as string[]