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
This commit is contained in:
Roo Code 2025-11-21 14:10:44 +00:00
parent 038f830bf6
commit ecfb3141e9
7 changed files with 345 additions and 17 deletions

View file

@ -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,

View file

@ -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:
<write_to_file>
<path>File path here</path>
@ -16,7 +24,26 @@ Your file content here
<line_count>total number of lines in the file, including empty lines</line_count>
</write_to_file>
Example: Requesting to write to frontend-config.json
Example 1: Creating a task-scoped markdown file for notes
<write_to_file>
<path>analysis-notes.md</path>
<content>
# 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
</content>
<line_count>10</line_count>
<task_scoped>true</task_scoped>
</write_to_file>
Example 2: Requesting to write to frontend-config.json
<write_to_file>
<path>frontend-config.json</path>
<content>

View file

@ -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"

View file

@ -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<string> {
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<string> {
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<string | null> {
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<string[]> {
const filesDir = await getTaskScopedFilesDirectory(globalStoragePath, taskId)
try {
const files: string[] = []
async function scanDirectory(dir: string, basePath: string = ""): Promise<void> {
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<boolean> {
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<boolean> {
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<TaskScopedFilesMetadata> {
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
}

View file

@ -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: `<file><path>${relPath}</path><error>${errorMsg}</error></file>`,
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: `<file><path>${relPath}</path><error>${errorMsg}</error></file>`,
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: `<file><path>${relPath}</path><error>Unable to access provider for task-scoped file</error></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: `<file><path>${relPath}</path><error>Task-scoped file not found</error></file>`,
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(`<content${lineRangeAttr}>\n${numberedContent}</content>`)
nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${numberedContent}`)
}
updateFileResult(relPath, {
xmlContent: `<file><path>${relPath}</path>\n${rangeResults.join("\n")}\n<notice>This is a task-scoped markdown file stored within the task context.</notice>\n</file>`,
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: `<file><path>${relPath}</path>\n<content${lineRangeAttr}>\n${numberedContent}</content>\n<notice>This is a task-scoped markdown file stored within the task context.</notice>\n</file>`,
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)])

View file

@ -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) {

View file

@ -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<string, unknown> }
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<Pick<Record<ToolParamName, string>, "path" | "content" | "line_count">>
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content" | "line_count" | "task_scoped">>
}
export interface InsertCodeBlockToolUse extends ToolUse<"insert_content"> {