From ecfb3141e9f47fd1f564768bd7e6057a09c13c38 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 21 Nov 2025 14:10:44 +0000 Subject: [PATCH] feat: add task-scoped markdown files feature - Add optional task_scoped parameter to write_to_file tool - Task-scoped files are stored within task context, not in project directory - Only .md and .markdown files can be task-scoped - Task-scoped files can be read using [task-scoped]/filename syntax - Add utilities for managing task-scoped files - Update tool documentation to explain task-scoped feature Fixes #9471 --- .../tools/native-tools/write_to_file.ts | 7 +- src/core/prompts/tools/write-to-file.ts | 29 +++- src/core/task-persistence/index.ts | 9 + src/core/task-persistence/taskScopedFiles.ts | 160 ++++++++++++++++++ src/core/tools/ReadFileTool.ts | 109 ++++++++++-- src/core/tools/WriteToFileTool.ts | 43 +++++ src/shared/tools.ts | 5 +- 7 files changed, 345 insertions(+), 17 deletions(-) create mode 100644 src/core/task-persistence/taskScopedFiles.ts diff --git a/src/core/prompts/tools/native-tools/write_to_file.ts b/src/core/prompts/tools/native-tools/write_to_file.ts index 7a88982bbb..06e730b996 100644 --- a/src/core/prompts/tools/native-tools/write_to_file.ts +++ b/src/core/prompts/tools/native-tools/write_to_file.ts @@ -5,7 +5,7 @@ export default { function: { name: "write_to_file", description: - "Create a new file or completely overwrite an existing file with the exact content provided. Use only when a full rewrite is intended; the tool will create missing directories automatically.", + "Create a new file or completely overwrite an existing file with the exact content provided. Use only when a full rewrite is intended; the tool will create missing directories automatically. Can create task-scoped markdown files that exist only within the current task.", strict: true, parameters: { type: "object", @@ -22,6 +22,11 @@ export default { type: "integer", description: "Total number of lines in the written file, counting blank lines", }, + task_scoped: { + type: "boolean", + description: + "If true, creates a task-scoped markdown file that exists only within the current task (only works for .md and .markdown files)", + }, }, required: ["path", "content", "line_count"], additionalProperties: false, diff --git a/src/core/prompts/tools/write-to-file.ts b/src/core/prompts/tools/write-to-file.ts index 221103b04f..4cf4718e9c 100644 --- a/src/core/prompts/tools/write-to-file.ts +++ b/src/core/prompts/tools/write-to-file.ts @@ -3,10 +3,18 @@ import { ToolArgs } from "./types" export function getWriteToFileDescription(args: ToolArgs): string { return `## write_to_file Description: Request to write content to a file. This tool is primarily used for **creating new files** or for scenarios where a **complete rewrite of an existing file is intentionally required**. If the file exists, it will be overwritten. If it doesn't exist, it will be created. This tool will automatically create any directories needed to write the file. + +**Task-Scoped Files**: You can create temporary markdown files that exist only within the current task by setting task_scoped to true. These files: +- Are stored within the task context, not in the project directory +- Can be accessed using [task-scoped]/filename.md syntax with read_file +- Must be markdown files (.md or .markdown) +- Are perfect for storing notes, analysis, or documentation during complex tasks + Parameters: - path: (required) The path of the file to write to (relative to the current workspace directory ${args.cwd}) - content: (required) The content to write to the file. When performing a full rewrite of an existing file or creating a new one, ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include the line numbers in the content though, just the actual content of the file. - line_count: (required) The number of lines in the file. Make sure to compute this based on the actual content of the file, not the number of lines in the content you're providing. +- task_scoped: (optional) Set to true to create a task-scoped markdown file that exists only within this task. Only works for .md and .markdown files. Usage: File path here @@ -16,7 +24,26 @@ Your file content here total number of lines in the file, including empty lines -Example: Requesting to write to frontend-config.json +Example 1: Creating a task-scoped markdown file for notes + +analysis-notes.md + +# Analysis Notes + +## Key Findings +- Found performance bottleneck in data processing +- Identified 3 areas for optimization + +## Next Steps +1. Implement caching strategy +2. Optimize database queries +3. Add performance monitoring + +10 +true + + +Example 2: Requesting to write to frontend-config.json frontend-config.json diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index c8656002bd..5de0c13b93 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,3 +1,12 @@ export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" +export { + writeTaskScopedFile, + readTaskScopedFile, + listTaskScopedFiles, + taskScopedFileExists, + deleteTaskScopedFile, + getTaskScopedFilesMetadata, + type TaskScopedFilesMetadata, +} from "./taskScopedFiles" diff --git a/src/core/task-persistence/taskScopedFiles.ts b/src/core/task-persistence/taskScopedFiles.ts new file mode 100644 index 0000000000..559c1216be --- /dev/null +++ b/src/core/task-persistence/taskScopedFiles.ts @@ -0,0 +1,160 @@ +import * as path from "path" +import * as fs from "fs/promises" +import { fileExistsAtPath } from "../../utils/fs" +import { getTaskDirectoryPath } from "../../utils/storage" +import { safeWriteJson } from "../../utils/safeWriteJson" + +const TASK_SCOPED_FILES_DIR = "task_files" + +/** + * Gets the directory path for task-scoped files + */ +export async function getTaskScopedFilesDirectory(globalStoragePath: string, taskId: string): Promise { + const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) + const filesDir = path.join(taskDir, TASK_SCOPED_FILES_DIR) + await fs.mkdir(filesDir, { recursive: true }) + return filesDir +} + +/** + * Writes a task-scoped file + */ +export async function writeTaskScopedFile( + globalStoragePath: string, + taskId: string, + filename: string, + content: string, +): Promise { + const filesDir = await getTaskScopedFilesDirectory(globalStoragePath, taskId) + const filePath = path.join(filesDir, filename) + + // Create subdirectories if needed + const fileDir = path.dirname(filePath) + await fs.mkdir(fileDir, { recursive: true }) + + // Write the file + await fs.writeFile(filePath, content, "utf-8") + + return filePath +} + +/** + * Reads a task-scoped file + */ +export async function readTaskScopedFile( + globalStoragePath: string, + taskId: string, + filename: string, +): Promise { + const filesDir = await getTaskScopedFilesDirectory(globalStoragePath, taskId) + const filePath = path.join(filesDir, filename) + + if (!(await fileExistsAtPath(filePath))) { + return null + } + + return await fs.readFile(filePath, "utf-8") +} + +/** + * Lists all task-scoped files + */ +export async function listTaskScopedFiles(globalStoragePath: string, taskId: string): Promise { + const filesDir = await getTaskScopedFilesDirectory(globalStoragePath, taskId) + + try { + const files: string[] = [] + + async function scanDirectory(dir: string, basePath: string = ""): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + const relativePath = basePath ? path.join(basePath, entry.name) : entry.name + + if (entry.isFile()) { + files.push(relativePath) + } else if (entry.isDirectory()) { + await scanDirectory(fullPath, relativePath) + } + } + } + + await scanDirectory(filesDir) + return files + } catch (error) { + // Directory might not exist yet + return [] + } +} + +/** + * Checks if a file exists as a task-scoped file + */ +export async function taskScopedFileExists( + globalStoragePath: string, + taskId: string, + filename: string, +): Promise { + const filesDir = await getTaskScopedFilesDirectory(globalStoragePath, taskId) + const filePath = path.join(filesDir, filename) + return await fileExistsAtPath(filePath) +} + +/** + * Deletes a task-scoped file + */ +export async function deleteTaskScopedFile( + globalStoragePath: string, + taskId: string, + filename: string, +): Promise { + const filesDir = await getTaskScopedFilesDirectory(globalStoragePath, taskId) + const filePath = path.join(filesDir, filename) + + try { + await fs.unlink(filePath) + return true + } catch (error) { + return false + } +} + +/** + * Gets metadata about task-scoped files for a task + */ +export interface TaskScopedFilesMetadata { + count: number + files: Array<{ + name: string + size: number + }> +} + +export async function getTaskScopedFilesMetadata( + globalStoragePath: string, + taskId: string, +): Promise { + const files = await listTaskScopedFiles(globalStoragePath, taskId) + const filesDir = await getTaskScopedFilesDirectory(globalStoragePath, taskId) + + const metadata: TaskScopedFilesMetadata = { + count: files.length, + files: [], + } + + for (const file of files) { + const filePath = path.join(filesDir, file) + try { + const stats = await fs.stat(filePath) + metadata.files.push({ + name: file, + size: stats.size, + }) + } catch (error) { + // Skip files that can't be accessed + } + } + + return metadata +} diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index d6989c103e..c2239c84d3 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -28,6 +28,7 @@ import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileToke import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { readTaskScopedFile } from "../task-persistence/taskScopedFiles" interface FileResult { path: string @@ -141,7 +142,18 @@ export class ReadFileTool extends BaseTool<"read_file"> { for (const fileResult of fileResults) { const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) + let fullPath: string + let isTaskScopedFile = false + + // Check if this is a task-scoped file + if (relPath.startsWith("[task-scoped]/")) { + isTaskScopedFile = true + // Extract the actual file path + const actualPath = relPath.substring("[task-scoped]/".length) + fullPath = actualPath // For task-scoped files, we'll use the relative path + } else { + fullPath = path.resolve(task.cwd, relPath) + } if (fileResult.lineRanges) { let hasRangeError = false @@ -175,17 +187,20 @@ export class ReadFileTool extends BaseTool<"read_file"> { } if (fileResult.status === "pending") { - const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await task.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - xmlContent: `${relPath}${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - continue + // Skip access checks for task-scoped files + if (!isTaskScopedFile) { + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + const errorMsg = formatResponse.rooIgnoreError(relPath) + updateFileResult(relPath, { + status: "blocked", + error: errorMsg, + xmlContent: `${relPath}${errorMsg}`, + nativeContent: `File: ${relPath}\nError: ${errorMsg}`, + }) + continue + } } filesToApprove.push(fileResult) @@ -333,7 +348,75 @@ export class ReadFileTool extends BaseTool<"read_file"> { if (fileResult.status !== "approved") continue const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) + let fullPath: string + let isTaskScopedFile = false + + // Check if this is a task-scoped file + if (relPath.startsWith("[task-scoped]/")) { + isTaskScopedFile = true + // Extract the actual file path + const actualPath = relPath.substring("[task-scoped]/".length) + fullPath = actualPath // For task-scoped files, we'll use the relative path + + // Read the task-scoped file + const provider = task.providerRef.deref() + if (!provider) { + updateFileResult(relPath, { + status: "error", + error: "Unable to access provider for task-scoped file", + xmlContent: `${relPath}Unable to access provider for task-scoped file`, + nativeContent: `File: ${relPath}\nError: Unable to access provider for task-scoped file`, + }) + continue + } + + const globalStoragePath = provider.context.globalStorageUri.fsPath + const content = await readTaskScopedFile(globalStoragePath, task.taskId, actualPath) + + if (content === null) { + updateFileResult(relPath, { + status: "error", + error: "Task-scoped file not found", + xmlContent: `${relPath}Task-scoped file not found`, + nativeContent: `File: ${relPath}\nError: Task-scoped file not found`, + }) + continue + } + + // Process the task-scoped file content + const lines = content.split("\n") + const totalLines = lines.length + + if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { + const rangeResults: string[] = [] + const nativeRangeResults: string[] = [] + + for (const range of fileResult.lineRanges) { + const contentLines = lines.slice(range.start - 1, range.end) + const numberedContent = addLineNumbers(contentLines.join("\n"), range.start) + const lineRangeAttr = ` lines="${range.start}-${range.end}"` + rangeResults.push(`\n${numberedContent}`) + nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${numberedContent}`) + } + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${rangeResults.join("\n")}\nThis is a task-scoped markdown file stored within the task context.\n`, + nativeContent: `File: ${relPath}\n${nativeRangeResults.join("\n\n")}\n\nNote: This is a task-scoped markdown file stored within the task context.`, + }) + } else { + const numberedContent = addLineNumbers(content, 1) + const lineRangeAttr = ` lines="1-${totalLines}"` + updateFileResult(relPath, { + xmlContent: `${relPath}\n\n${numberedContent}\nThis is a task-scoped markdown file stored within the task context.\n`, + nativeContent: `File: ${relPath}\nLines 1-${totalLines}:\n${numberedContent}\n\nNote: This is a task-scoped markdown file stored within the task context.`, + }) + } + + await task.fileContextTracker.trackFileContext(actualPath, "read_tool" as RecordSource) + continue + } + + fullPath = path.resolve(task.cwd, relPath) try { const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 4c355beb07..803217a737 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -19,11 +19,13 @@ import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } fr import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { writeTaskScopedFile } from "../task-persistence/taskScopedFiles" interface WriteToFileParams { path: string content: string line_count: number + task_scoped?: boolean } export class WriteToFileTool extends BaseTool<"write_to_file"> { @@ -34,6 +36,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { path: params.path || "", content: params.content || "", line_count: parseInt(params.line_count ?? "0", 10), + task_scoped: params.task_scoped === "true", } } @@ -42,6 +45,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const relPath = params.path let newContent = params.content const predictedLineCount = params.line_count + const isTaskScoped = params.task_scoped || false if (!relPath) { task.consecutiveMistakeCount++ @@ -59,6 +63,45 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } + // Handle task-scoped files specially + if (isTaskScoped) { + try { + // For task-scoped files, only allow markdown files + if (!relPath.endsWith(".md") && !relPath.endsWith(".markdown")) { + pushToolResult( + formatResponse.toolError("Task-scoped files must be markdown files (.md or .markdown)"), + ) + return + } + + const provider = task.providerRef.deref() + if (!provider) { + pushToolResult(formatResponse.toolError("Unable to access provider for task-scoped file")) + return + } + + const globalStoragePath = provider.context.globalStorageUri.fsPath + const filePath = await writeTaskScopedFile(globalStoragePath, task.taskId, relPath, newContent) + + // Notify that we created a task-scoped file + await task.say( + "text", + `📝 Created task-scoped markdown file: ${relPath}\nThis file exists only within this task and won't appear in your project directory.`, + ) + + pushToolResult( + formatResponse.toolResult( + `Task-scoped file created: ${relPath}\n\nThis markdown file is stored within the task context and won't appear in your project directory.`, + ), + ) + task.consecutiveMistakeCount = 0 + return + } catch (error) { + await handleError("writing task-scoped file", error as Error) + return + } + } + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f280dcd411..f9de20c964 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -71,6 +71,7 @@ export const toolParamNames = [ "prompt", "image", "files", // Native protocol parameter for read_file + "task_scoped", // Parameter to mark files as task-scoped ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -101,7 +102,7 @@ export type NativeToolArgs = { switch_mode: { mode_slug: string; reason: string } update_todo_list: { todos: string } use_mcp_tool: { server_name: string; tool_name: string; arguments?: Record } - write_to_file: { path: string; content: string; line_count: number } + write_to_file: { path: string; content: string; line_count: number; task_scoped?: boolean } // Add more tools as they are migrated to native protocol } @@ -139,7 +140,7 @@ export interface FetchInstructionsToolUse extends ToolUse<"fetch_instructions"> export interface WriteToFileToolUse extends ToolUse<"write_to_file"> { name: "write_to_file" - params: Partial, "path" | "content" | "line_count">> + params: Partial, "path" | "content" | "line_count" | "task_scoped">> } export interface InsertCodeBlockToolUse extends ToolUse<"insert_content"> {