mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
038f830bf6
commit
ecfb3141e9
7 changed files with 345 additions and 17 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
160
src/core/task-persistence/taskScopedFiles.ts
Normal file
160
src/core/task-persistence/taskScopedFiles.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue