203 lines
5.2 KiB
TypeScript
203 lines
5.2 KiB
TypeScript
// src/tools/file/write.ts
|
|
// Copyright (C) 2025 DTP Technologies, LLC
|
|
// All Rights Reserved
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import type { ToolDefinition } from "../../lib/ai-client.js";
|
|
import { ChatSessionMode } from "@/models/chat-session.js";
|
|
import type { IChatFileOperation } from "../../models/chat-history.js";
|
|
|
|
import {
|
|
DtpTool,
|
|
ToolMetadata,
|
|
type ToolArguments,
|
|
type ToolContext,
|
|
} from "../../lib/tool.js";
|
|
import { validateProjectPath } from "../../lib/path-security.js";
|
|
import { PROJECT_ROOT } from "../../config/env.js";
|
|
import HostMonitorService from "../../services/host-monitor.js";
|
|
|
|
const BINARY_EXTENSIONS = new Set([
|
|
".o",
|
|
".obj",
|
|
".a",
|
|
".lib",
|
|
".so",
|
|
".dll",
|
|
".exe",
|
|
".bin",
|
|
".png",
|
|
".jpg",
|
|
".jpeg",
|
|
".gif",
|
|
".bmp",
|
|
".ico",
|
|
".webp",
|
|
".pdf",
|
|
".zip",
|
|
".tar",
|
|
".gz",
|
|
".bz2",
|
|
".7z",
|
|
".wasm",
|
|
".pyc",
|
|
".class",
|
|
]);
|
|
|
|
export class FileWriteTool extends DtpTool {
|
|
get name(): string {
|
|
return "FileWriteTool";
|
|
}
|
|
get slug(): string {
|
|
return "file-write";
|
|
}
|
|
get metadata(): ToolMetadata {
|
|
return {
|
|
name: this.definition.function.name || "file_write",
|
|
category: "file",
|
|
tags: ["file", "write", "create", "save"],
|
|
modes: [
|
|
ChatSessionMode.Plan,
|
|
ChatSessionMode.Build,
|
|
ChatSessionMode.Test,
|
|
ChatSessionMode.Ship,
|
|
ChatSessionMode.Develop,
|
|
],
|
|
};
|
|
}
|
|
|
|
public definition: ToolDefinition = {
|
|
type: "function",
|
|
function: {
|
|
name: "file_write",
|
|
description:
|
|
"Create a new file or overwrite an existing file with the given content. Parent directories are created automatically.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description:
|
|
"Path to the file to create or overwrite (relative or absolute).",
|
|
},
|
|
content: {
|
|
type: "string",
|
|
description: "The content to write to the file.",
|
|
},
|
|
},
|
|
required: ["path", "content"],
|
|
},
|
|
},
|
|
};
|
|
|
|
public async execute(
|
|
_context: ToolContext,
|
|
args: ToolArguments,
|
|
): Promise<string> {
|
|
const filePath = args.path as string | undefined;
|
|
const content = args.content as string | undefined;
|
|
|
|
if (!filePath || filePath.trim().length === 0) {
|
|
return this.error("MISSING_PARAMETER", "File path must not be empty.", {
|
|
parameter: "path",
|
|
recoveryHint: "Provide a valid file path.",
|
|
});
|
|
}
|
|
|
|
if (content === undefined) {
|
|
return this.error("MISSING_PARAMETER", "Content must not be undefined.", {
|
|
parameter: "content",
|
|
recoveryHint: "Provide the content to write to the file.",
|
|
});
|
|
}
|
|
|
|
// Validate path security - prevent path traversal attacks
|
|
const pathValidation = validateProjectPath(filePath, PROJECT_ROOT);
|
|
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
|
return this.error(
|
|
"SECURITY_VIOLATION",
|
|
pathValidation.error || "Invalid path",
|
|
{
|
|
parameter: "path",
|
|
recoveryHint:
|
|
"Provide a valid relative path within the project directory.",
|
|
},
|
|
);
|
|
}
|
|
|
|
const resolvedPath = pathValidation.resolvedPath;
|
|
|
|
try {
|
|
const dir = path.dirname(resolvedPath);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
|
|
// Capture existing content for diff (if file exists)
|
|
let oldContent: string | undefined;
|
|
let isExisting = false;
|
|
try {
|
|
oldContent = await fs.readFile(resolvedPath, "utf-8");
|
|
isExisting = true;
|
|
} catch {
|
|
// new file - no previous content
|
|
}
|
|
|
|
const contentStr = String(content);
|
|
await fs.writeFile(resolvedPath, contentStr, "utf-8");
|
|
|
|
const byteCount = Buffer.byteLength(contentStr, "utf-8");
|
|
const created = !isExisting;
|
|
|
|
const binary = BINARY_EXTENSIONS.has(
|
|
path.extname(filePath).toLowerCase(),
|
|
);
|
|
const fileOperation: IChatFileOperation = {
|
|
type: "write",
|
|
path: filePath,
|
|
isBinary: binary,
|
|
};
|
|
if (!binary) {
|
|
const newLines = contentStr.split("\n").length;
|
|
const oldLines = oldContent ? oldContent.split("\n").length : 0;
|
|
fileOperation.linesAdded = isExisting
|
|
? Math.max(0, newLines - oldLines)
|
|
: newLines;
|
|
fileOperation.linesRemoved = isExisting
|
|
? Math.max(0, oldLines - newLines)
|
|
: 0;
|
|
}
|
|
|
|
HostMonitorService.fileOperation(byteCount);
|
|
HostMonitorService.toolCall(byteCount);
|
|
|
|
// Build plain text response with metadata header
|
|
const outputLines = [
|
|
`PATH: ${filePath}`,
|
|
`FILE OPERATION: write`,
|
|
`CREATED: ${created ? "true" : "false"}`,
|
|
`BYTES WRITTEN: ${byteCount}`,
|
|
"---",
|
|
`File written: ${filePath} (${byteCount} bytes)`,
|
|
];
|
|
|
|
return outputLines.join("\n");
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
|
|
this.log.error("Failed to write file", {
|
|
path: filePath,
|
|
error: errorMessage,
|
|
});
|
|
|
|
return this.error(
|
|
"OPERATION_FAILED",
|
|
`Failed to write file: ${errorMessage}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export default new FileWriteTool();
|