mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
feat: Add file context tracking system (#2440)
* feat: Add file context tracking system This commit adds a comprehensive file context tracking system that monitors file operations (reads, edits) by both Roo and users. The system helps prevent stale context issues and improves checkpoint management. Key features: - Track files accessed via tools, mentions, or edits - Monitor file changes outside of Roo using file watchers - Store file operation metadata with timestamps - Trigger checkpoints automatically when files are modified - Prevent false positives by distinguishing between Roo and user edits The implementation includes: - New FileContextTracker class to manage file operations - Type definitions for file metadata tracking - Integration with all file-related tools - File mention tracking in the mentions system - Improved checkpoint triggering based on file modifications * Update src/core/context-tracking/FileContextTracker.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update src/core/context-tracking/FileContextTracker.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * test: Add mocks for getFileContextTracker in Cline tests --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
parent
0ddfa4d0bf
commit
5352beb95c
14 changed files with 410 additions and 66 deletions
|
|
@ -53,6 +53,7 @@ import { calculateApiCostAnthropic } from "../utils/cost"
|
|||
import { fileExistsAtPath } from "../utils/fs"
|
||||
import { arePathsEqual } from "../utils/path"
|
||||
import { parseMentions } from "./mentions"
|
||||
import { FileContextTracker } from "./context-tracking/FileContextTracker"
|
||||
import { RooIgnoreController } from "./ignore/RooIgnoreController"
|
||||
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
|
||||
import { formatResponse } from "./prompts/responses"
|
||||
|
|
@ -130,6 +131,7 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
|
||||
readonly apiConfiguration: ApiConfiguration
|
||||
api: ApiHandler
|
||||
private fileContextTracker: FileContextTracker
|
||||
private urlContentFetcher: UrlContentFetcher
|
||||
browserSession: BrowserSession
|
||||
didEditFile: boolean = false
|
||||
|
|
@ -201,14 +203,15 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
throw new Error("Either historyItem or task/images must be provided")
|
||||
}
|
||||
|
||||
this.rooIgnoreController = new RooIgnoreController(this.cwd)
|
||||
this.rooIgnoreController.initialize().catch((error) => {
|
||||
console.error("Failed to initialize RooIgnoreController:", error)
|
||||
})
|
||||
|
||||
this.taskId = historyItem ? historyItem.id : crypto.randomUUID()
|
||||
this.instanceId = crypto.randomUUID().slice(0, 8)
|
||||
this.taskNumber = -1
|
||||
|
||||
this.rooIgnoreController = new RooIgnoreController(this.cwd)
|
||||
this.fileContextTracker = new FileContextTracker(provider, this.taskId)
|
||||
this.rooIgnoreController.initialize().catch((error) => {
|
||||
console.error("Failed to initialize RooIgnoreController:", error)
|
||||
})
|
||||
this.apiConfiguration = apiConfiguration
|
||||
this.api = buildApiHandler(apiConfiguration)
|
||||
this.urlContentFetcher = new UrlContentFetcher(provider.context)
|
||||
|
|
@ -929,6 +932,7 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
this.urlContentFetcher.closeBrowser()
|
||||
this.browserSession.closeBrowser()
|
||||
this.rooIgnoreController?.dispose()
|
||||
this.fileContextTracker.dispose()
|
||||
|
||||
// If we're not streaming then `abortStream` (which reverts the diff
|
||||
// view changes) won't be called, so we need to revert the changes here.
|
||||
|
|
@ -1322,8 +1326,6 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
|
||||
const block = cloneDeep(this.assistantMessageContent[this.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
|
||||
|
||||
let isCheckpointPossible = false
|
||||
|
||||
switch (block.type) {
|
||||
case "text": {
|
||||
if (this.didRejectTool || this.didAlreadyUseTool) {
|
||||
|
|
@ -1460,7 +1462,6 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
|
||||
// Flag a checkpoint as possible since we've used a tool
|
||||
// which may have changed the file system.
|
||||
isCheckpointPossible = true
|
||||
}
|
||||
|
||||
const askApproval = async (
|
||||
|
|
@ -1583,6 +1584,7 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
break
|
||||
case "read_file":
|
||||
await readFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
|
||||
|
||||
break
|
||||
case "fetch_instructions":
|
||||
await fetchInstructionsTool(this, block, askApproval, handleError, pushToolResult)
|
||||
|
|
@ -1662,7 +1664,9 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
break
|
||||
}
|
||||
|
||||
if (isCheckpointPossible) {
|
||||
const recentlyModifiedFiles = this.fileContextTracker.getAndClearCheckpointPossibleFile()
|
||||
if (recentlyModifiedFiles.length > 0) {
|
||||
// TODO: we can track what file changes were made and only checkpoint those files, this will be save storage
|
||||
this.checkpointSave()
|
||||
}
|
||||
|
||||
|
|
@ -1783,18 +1787,17 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
)
|
||||
|
||||
const [parsedUserContent, environmentDetails] = await this.loadContext(userContent, includeFileDetails)
|
||||
userContent = parsedUserContent
|
||||
// add environment details as its own text block, separate from tool results
|
||||
userContent.push({ type: "text", text: environmentDetails })
|
||||
const finalUserContent = [...parsedUserContent, { type: "text", text: environmentDetails }] as UserContent
|
||||
|
||||
await this.addToApiConversationHistory({ role: "user", content: userContent })
|
||||
await this.addToApiConversationHistory({ role: "user", content: finalUserContent })
|
||||
telemetryService.captureConversationMessage(this.taskId, "user")
|
||||
|
||||
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
|
||||
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
|
||||
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
|
||||
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
|
||||
request: finalUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
|
||||
} satisfies ClineApiReqInfo)
|
||||
|
||||
await this.saveClineMessages()
|
||||
|
|
@ -2045,62 +2048,73 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
}
|
||||
|
||||
async loadContext(userContent: UserContent, includeFileDetails: boolean = false) {
|
||||
return await Promise.all([
|
||||
// Process userContent array, which contains various block types:
|
||||
// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
|
||||
// We need to apply parseMentions() to:
|
||||
// 1. All TextBlockParam's text (first user message with task)
|
||||
// 2. ToolResultBlockParam's content/context text arrays if it contains "<feedback>" (see formatToolDeniedFeedback, attemptCompletion, executeCommand, and consecutiveMistakeCount >= 3) or "<answer>" (see askFollowupQuestion), we place all user generated content in these tags so they can effectively be used as markers for when we should parse mentions)
|
||||
Promise.all(
|
||||
userContent.map(async (block) => {
|
||||
const shouldProcessMentions = (text: string) =>
|
||||
text.includes("<task>") || text.includes("<feedback>")
|
||||
// Process userContent array, which contains various block types:
|
||||
// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
|
||||
// We need to apply parseMentions() to:
|
||||
// 1. All TextBlockParam's text (first user message with task)
|
||||
// 2. ToolResultBlockParam's content/context text arrays if it contains "<feedback>" (see formatToolDeniedFeedback, attemptCompletion, executeCommand, and consecutiveMistakeCount >= 3) or "<answer>" (see askFollowupQuestion), we place all user generated content in these tags so they can effectively be used as markers for when we should parse mentions)
|
||||
const parsedUserContent = await Promise.all(
|
||||
userContent.map(async (block) => {
|
||||
const shouldProcessMentions = (text: string) => text.includes("<task>") || text.includes("<feedback>")
|
||||
|
||||
if (block.type === "text") {
|
||||
if (shouldProcessMentions(block.text)) {
|
||||
return {
|
||||
...block,
|
||||
text: await parseMentions(block.text, this.cwd, this.urlContentFetcher),
|
||||
}
|
||||
if (block.type === "text") {
|
||||
if (shouldProcessMentions(block.text)) {
|
||||
return {
|
||||
...block,
|
||||
text: await parseMentions(
|
||||
block.text,
|
||||
this.cwd,
|
||||
this.urlContentFetcher,
|
||||
this.fileContextTracker,
|
||||
),
|
||||
}
|
||||
return block
|
||||
} else if (block.type === "tool_result") {
|
||||
if (typeof block.content === "string") {
|
||||
if (shouldProcessMentions(block.content)) {
|
||||
return {
|
||||
...block,
|
||||
content: await parseMentions(block.content, this.cwd, this.urlContentFetcher),
|
||||
}
|
||||
}
|
||||
return block
|
||||
} else if (Array.isArray(block.content)) {
|
||||
const parsedContent = await Promise.all(
|
||||
block.content.map(async (contentBlock) => {
|
||||
if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
|
||||
return {
|
||||
...contentBlock,
|
||||
text: await parseMentions(
|
||||
contentBlock.text,
|
||||
this.cwd,
|
||||
this.urlContentFetcher,
|
||||
),
|
||||
}
|
||||
}
|
||||
return contentBlock
|
||||
}),
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: parsedContent,
|
||||
}
|
||||
}
|
||||
return block
|
||||
}
|
||||
return block
|
||||
}),
|
||||
),
|
||||
this.getEnvironmentDetails(includeFileDetails),
|
||||
])
|
||||
} else if (block.type === "tool_result") {
|
||||
if (typeof block.content === "string") {
|
||||
if (shouldProcessMentions(block.content)) {
|
||||
return {
|
||||
...block,
|
||||
content: await parseMentions(
|
||||
block.content,
|
||||
this.cwd,
|
||||
this.urlContentFetcher,
|
||||
this.fileContextTracker,
|
||||
),
|
||||
}
|
||||
}
|
||||
return block
|
||||
} else if (Array.isArray(block.content)) {
|
||||
const parsedContent = await Promise.all(
|
||||
block.content.map(async (contentBlock) => {
|
||||
if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
|
||||
return {
|
||||
...contentBlock,
|
||||
text: await parseMentions(
|
||||
contentBlock.text,
|
||||
this.cwd,
|
||||
this.urlContentFetcher,
|
||||
this.fileContextTracker,
|
||||
),
|
||||
}
|
||||
}
|
||||
return contentBlock
|
||||
}),
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: parsedContent,
|
||||
}
|
||||
}
|
||||
return block
|
||||
}
|
||||
return block
|
||||
}),
|
||||
)
|
||||
|
||||
const environmentDetails = await this.getEnvironmentDetails(includeFileDetails)
|
||||
|
||||
return [parsedUserContent, environmentDetails]
|
||||
}
|
||||
|
||||
async getEnvironmentDetails(includeFileDetails: boolean = false) {
|
||||
|
|
@ -2251,6 +2265,16 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
// details += "\n(No errors detected)"
|
||||
// }
|
||||
|
||||
// Add recently modified files section
|
||||
const recentlyModifiedFiles = this.fileContextTracker.getAndClearRecentlyModifiedFiles()
|
||||
if (recentlyModifiedFiles.length > 0) {
|
||||
details +=
|
||||
"\n\n# Recently Modified Files\nThese files have been modified since you last accessed them (file was just edited so you may need to re-read it before editing):"
|
||||
for (const filePath of recentlyModifiedFiles) {
|
||||
details += `\n${filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalDetails) {
|
||||
details += terminalDetails
|
||||
}
|
||||
|
|
@ -2619,4 +2643,9 @@ export class Cline extends EventEmitter<ClineEvents> {
|
|||
this.enableCheckpoints = false
|
||||
}
|
||||
}
|
||||
|
||||
// Public accessor for fileContextTracker
|
||||
public getFileContextTracker(): FileContextTracker {
|
||||
return this.fileContextTracker
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ import { ApiStreamChunk } from "../../api/transform/stream"
|
|||
// Mock RooIgnoreController
|
||||
jest.mock("../ignore/RooIgnoreController")
|
||||
|
||||
// Mock storagePathManager to prevent dynamic import issues
|
||||
jest.mock("../../shared/storagePathManager", () => ({
|
||||
getTaskDirectoryPath: jest.fn().mockImplementation((globalStoragePath, taskId) => {
|
||||
return Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)
|
||||
}),
|
||||
getSettingsDirectoryPath: jest.fn().mockImplementation((globalStoragePath) => {
|
||||
return Promise.resolve(`${globalStoragePath}/settings`)
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock fileExistsAtPath
|
||||
jest.mock("../../utils/fs", () => ({
|
||||
fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
|
||||
|
|
@ -941,6 +951,7 @@ describe("Cline", () => {
|
|||
"<task>Text with @/some/path in task tags</task>",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
|
||||
// Feedback tag content should be processed
|
||||
|
|
@ -951,6 +962,7 @@ describe("Cline", () => {
|
|||
"<feedback>Check @/some/path</feedback>",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
|
||||
// Regular tool result should not be processed
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@ describe("read_file tool with maxReadFileLine setting", () => {
|
|||
mockCline.say = jest.fn().mockResolvedValue(undefined)
|
||||
mockCline.ask = jest.fn().mockResolvedValue(true)
|
||||
mockCline.presentAssistantMessage = jest.fn()
|
||||
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
|
||||
trackFileContext: jest.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
|
||||
// Reset tool result
|
||||
toolResult = undefined
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ describe("read_file tool XML output structure", () => {
|
|||
mockCline.ask = jest.fn().mockResolvedValue(true)
|
||||
mockCline.presentAssistantMessage = jest.fn()
|
||||
mockCline.sayAndCreateMissingParamError = jest.fn().mockResolvedValue("Missing required parameter")
|
||||
// Add mock for getFileContextTracker method
|
||||
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
|
||||
trackFileContext: jest.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
|
||||
// Reset tool result
|
||||
toolResult = undefined
|
||||
|
|
|
|||
225
src/core/context-tracking/FileContextTracker.ts
Normal file
225
src/core/context-tracking/FileContextTracker.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { getTaskDirectoryPath } from "../../shared/storagePathManager"
|
||||
import { GlobalFileNames } from "../../shared/globalFileNames"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import { ContextProxy } from "../config/ContextProxy"
|
||||
import type { FileMetadataEntry, RecordSource, TaskMetadata } from "./FileContextTrackerTypes"
|
||||
import { ClineProvider } from "../webview/ClineProvider"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Roo, the context may become stale and need to be updated.
|
||||
// We do not want Roo to reload the context every time a file is modified, so we use this class merely
|
||||
// to inform Roo that the change has occurred, and tell Roo to reload the file before making
|
||||
// any changes to it. This fixes an issue with diff editing, where Roo was unable to complete a diff edit.
|
||||
|
||||
// FileContextTracker
|
||||
//
|
||||
// This class is responsible for tracking file operations.
|
||||
// If the full contents of a file are passed to Roo via a tool, mention, or edit, the file is marked as active.
|
||||
// If a file is modified outside of Roo, we detect and track this change to prevent stale context.
|
||||
export class FileContextTracker {
|
||||
readonly taskId: string
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
|
||||
// File tracking and watching
|
||||
private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
|
||||
private recentlyModifiedFiles = new Set<string>()
|
||||
private recentlyEditedByRoo = new Set<string>()
|
||||
private checkpointPossibleFiles = new Set<string>()
|
||||
|
||||
constructor(provider: ClineProvider, taskId: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
// Gets the current working directory or returns undefined if it cannot be determined
|
||||
private getCwd(): string | undefined {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
// File watchers are set up for each file that is tracked in the task metadata.
|
||||
async setupFileWatcher(filePath: string) {
|
||||
// Only setup watcher if it doesn't already exist for this file
|
||||
if (this.fileWatchers.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a file system watcher for this specific file
|
||||
const fileUri = vscode.Uri.file(path.resolve(cwd, filePath))
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(path.dirname(fileUri.fsPath), path.basename(fileUri.fsPath)),
|
||||
)
|
||||
|
||||
// Track file changes
|
||||
watcher.onDidChange(() => {
|
||||
if (this.recentlyEditedByRoo.has(filePath)) {
|
||||
this.recentlyEditedByRoo.delete(filePath) // This was an edit by Roo, no need to inform Roo
|
||||
} else {
|
||||
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Roo
|
||||
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
|
||||
}
|
||||
})
|
||||
|
||||
// Store the watcher so we can dispose it later
|
||||
this.fileWatchers.set(filePath, watcher)
|
||||
}
|
||||
|
||||
// Tracks a file operation in metadata and sets up a watcher for the file
|
||||
// This is the main entry point for FileContextTracker and is called when a file is passed to Roo via a tool, mention, or edit.
|
||||
async trackFileContext(filePath: string, operation: RecordSource) {
|
||||
try {
|
||||
const cwd = this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.addFileToFileContextTracker(this.taskId, filePath, operation)
|
||||
|
||||
// Set up file watcher for this file
|
||||
await this.setupFileWatcher(filePath)
|
||||
} catch (error) {
|
||||
console.error("Failed to track file operation:", error)
|
||||
}
|
||||
}
|
||||
|
||||
public getContextProxy(): ContextProxy | undefined {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
console.error("ClineProvider reference is no longer valid")
|
||||
return undefined
|
||||
}
|
||||
const context = provider.contextProxy
|
||||
|
||||
if (!context) {
|
||||
console.error("Context is not available")
|
||||
return undefined
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// Gets task metadata from storage
|
||||
async getTaskMetadata(taskId: string): Promise<TaskMetadata> {
|
||||
const globalStoragePath = this.getContextProxy()?.globalStorageUri.fsPath ?? ''
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read task metadata:", error)
|
||||
}
|
||||
return { files_in_context: [] }
|
||||
}
|
||||
|
||||
// Saves task metadata to storage
|
||||
async saveTaskMetadata(taskId: string, metadata: TaskMetadata) {
|
||||
try {
|
||||
const globalStoragePath = this.getContextProxy()!.globalStorageUri.fsPath
|
||||
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
|
||||
} catch (error) {
|
||||
console.error("Failed to save task metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a file to the metadata tracker
|
||||
// This handles the business logic of determining if the file is new, stale, or active.
|
||||
// It also updates the metadata with the latest read/edit dates.
|
||||
async addFileToFileContextTracker(taskId: string, filePath: string, source: RecordSource) {
|
||||
try {
|
||||
const metadata = await this.getTaskMetadata(taskId)
|
||||
const now = Date.now()
|
||||
|
||||
// Mark existing entries for this file as stale
|
||||
metadata.files_in_context.forEach((entry) => {
|
||||
if (entry.path === filePath && entry.record_state === "active") {
|
||||
entry.record_state = "stale"
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to get the latest date for a specific field and file
|
||||
const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
|
||||
const relevantEntries = metadata.files_in_context
|
||||
.filter((entry) => entry.path === path && entry[field])
|
||||
.sort((a, b) => (b[field] as number) - (a[field] as number))
|
||||
|
||||
return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
|
||||
}
|
||||
|
||||
let newEntry: FileMetadataEntry = {
|
||||
path: filePath,
|
||||
record_state: "active",
|
||||
record_source: source,
|
||||
roo_read_date: getLatestDateForField(filePath, "roo_read_date"),
|
||||
roo_edit_date: getLatestDateForField(filePath, "roo_edit_date"),
|
||||
user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
// user_edited: The user has edited the file
|
||||
case "user_edited":
|
||||
newEntry.user_edit_date = now
|
||||
this.recentlyModifiedFiles.add(filePath)
|
||||
break
|
||||
|
||||
// roo_edited: Roo has edited the file
|
||||
case "roo_edited":
|
||||
newEntry.roo_read_date = now
|
||||
newEntry.roo_edit_date = now
|
||||
this.checkpointPossibleFiles.add(filePath)
|
||||
break
|
||||
|
||||
// read_tool/file_mentioned: Roo has read the file via a tool or file mention
|
||||
case "read_tool":
|
||||
case "file_mentioned":
|
||||
newEntry.roo_read_date = now
|
||||
break
|
||||
}
|
||||
|
||||
metadata.files_in_context.push(newEntry)
|
||||
await this.saveTaskMetadata(taskId, metadata)
|
||||
} catch (error) {
|
||||
console.error("Failed to add file to metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns (and then clears) the set of recently modified files
|
||||
getAndClearRecentlyModifiedFiles(): string[] {
|
||||
const files = Array.from(this.recentlyModifiedFiles)
|
||||
this.recentlyModifiedFiles.clear()
|
||||
return files
|
||||
}
|
||||
|
||||
getAndClearCheckpointPossibleFile(): string[] {
|
||||
const files = Array.from(this.checkpointPossibleFiles)
|
||||
this.checkpointPossibleFiles.clear()
|
||||
return files
|
||||
}
|
||||
|
||||
// Marks a file as edited by Roo to prevent false positives in file watchers
|
||||
markFileAsEditedByRoo(filePath: string): void {
|
||||
this.recentlyEditedByRoo.add(filePath)
|
||||
}
|
||||
|
||||
// Disposes all file watchers
|
||||
dispose(): void {
|
||||
for (const watcher of this.fileWatchers.values()) {
|
||||
watcher.dispose()
|
||||
}
|
||||
this.fileWatchers.clear()
|
||||
}
|
||||
}
|
||||
28
src/core/context-tracking/FileContextTrackerTypes.ts
Normal file
28
src/core/context-tracking/FileContextTrackerTypes.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { z } from "zod"
|
||||
|
||||
// Zod schema for RecordSource
|
||||
export const recordSourceSchema = z.enum(["read_tool", "user_edited", "roo_edited", "file_mentioned"])
|
||||
|
||||
// TypeScript type derived from the Zod schema
|
||||
export type RecordSource = z.infer<typeof recordSourceSchema>
|
||||
|
||||
// Zod schema for FileMetadataEntry
|
||||
export const fileMetadataEntrySchema = z.object({
|
||||
path: z.string(),
|
||||
record_state: z.enum(["active", "stale"]),
|
||||
record_source: recordSourceSchema,
|
||||
roo_read_date: z.number().nullable(),
|
||||
roo_edit_date: z.number().nullable(),
|
||||
user_edit_date: z.number().nullable().optional(),
|
||||
})
|
||||
|
||||
// TypeScript type derived from the Zod schema
|
||||
export type FileMetadataEntry = z.infer<typeof fileMetadataEntrySchema>
|
||||
|
||||
// Zod schema for TaskMetadata
|
||||
export const taskMetadataSchema = z.object({
|
||||
files_in_context: z.array(fileMetadataEntrySchema),
|
||||
})
|
||||
|
||||
// TypeScript type derived from the Zod schema
|
||||
export type TaskMetadata = z.infer<typeof taskMetadataSchema>
|
||||
|
|
@ -10,6 +10,7 @@ import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
|
|||
import { getCommitInfo, getWorkingState } from "../../utils/git"
|
||||
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
|
||||
export async function openMention(mention?: string): Promise<void> {
|
||||
if (!mention) {
|
||||
|
|
@ -38,7 +39,12 @@ export async function openMention(mention?: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function parseMentions(text: string, cwd: string, urlContentFetcher: UrlContentFetcher): Promise<string> {
|
||||
export async function parseMentions(
|
||||
text: string,
|
||||
cwd: string,
|
||||
urlContentFetcher: UrlContentFetcher,
|
||||
fileContextTracker?: FileContextTracker,
|
||||
): Promise<string> {
|
||||
const mentions: Set<string> = new Set()
|
||||
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
|
||||
mentions.add(mention)
|
||||
|
|
@ -95,6 +101,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
|||
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
|
||||
} else {
|
||||
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
|
||||
// Track that this file was mentioned and its content was included
|
||||
if (fileContextTracker) {
|
||||
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (mention.endsWith("/")) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { fileExistsAtPath } from "../../utils/fs"
|
|||
import { addLineNumbers } from "../../integrations/misc/extract-text"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
||||
|
||||
export async function applyDiffTool(
|
||||
cline: Cline,
|
||||
|
|
@ -138,6 +139,10 @@ export async function applyDiffTool(
|
|||
}
|
||||
|
||||
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
|
||||
// Track file edit operation
|
||||
if (relPath) {
|
||||
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
|
||||
}
|
||||
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
|
||||
let partFailHint = ""
|
||||
if (diffResult.failParts && diffResult.failParts.length > 0) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./ty
|
|||
import { formatResponse } from "../prompts/responses"
|
||||
import { ClineSayTool } from "../../shared/ExtensionMessage"
|
||||
import path from "path"
|
||||
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { insertGroups } from "../diff/insert-groups"
|
||||
import delay from "delay"
|
||||
|
|
@ -127,6 +128,11 @@ export async function insertContentTool(
|
|||
}
|
||||
|
||||
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
|
||||
|
||||
// Track file edit operation
|
||||
if (relPath) {
|
||||
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
|
||||
}
|
||||
cline.didEditFile = true
|
||||
|
||||
if (!userEdits) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getReadablePath } from "../../utils/path"
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { parseSourceCodeForDefinitionsTopLevel, parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
|
||||
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
||||
|
||||
export async function listCodeDefinitionNamesTool(
|
||||
cline: Cline,
|
||||
|
|
@ -59,6 +60,9 @@ export async function listCodeDefinitionNamesTool(
|
|||
if (!didApprove) {
|
||||
return
|
||||
}
|
||||
if (relPath) {
|
||||
await cline.getFileContextTracker().trackFileContext(relPath, "read_tool" as RecordSource)
|
||||
}
|
||||
pushToolResult(result)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ToolUse } from "../assistant-message"
|
|||
import { formatResponse } from "../prompts/responses"
|
||||
import { t } from "../../i18n"
|
||||
import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types"
|
||||
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
||||
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
|
||||
import { getReadablePath } from "../../utils/path"
|
||||
import { countFileLines } from "../../integrations/misc/line-counter"
|
||||
|
|
@ -216,6 +217,11 @@ export async function readFileTool(
|
|||
contentTag = `<content${lineRangeAttr}>\n${content}</content>\n`
|
||||
}
|
||||
|
||||
// Track file read operation
|
||||
if (relPath) {
|
||||
await cline.getFileContextTracker().trackFileContext(relPath, "read_tool" as RecordSource)
|
||||
}
|
||||
|
||||
// Format the result into the required XML structure
|
||||
const xmlResult = `<file><path>${relPath}</path>\n${contentTag}${xmlInfo}</file>`
|
||||
pushToolResult(xmlResult)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import path from "path"
|
|||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { addLineNumbers } from "../../integrations/misc/extract-text"
|
||||
import fs from "fs/promises"
|
||||
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
||||
|
||||
export async function searchAndReplaceTool(
|
||||
cline: Cline,
|
||||
|
|
@ -143,6 +144,10 @@ export async function searchAndReplaceTool(
|
|||
}
|
||||
|
||||
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
|
||||
if (relPath) {
|
||||
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
|
||||
}
|
||||
|
||||
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
|
||||
if (userEdits) {
|
||||
await cline.say(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ClineSayTool } from "../../shared/ExtensionMessage"
|
|||
import { ToolUse } from "../assistant-message"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types"
|
||||
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
||||
import path from "path"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { addLineNumbers, stripLineNumbers } from "../../integrations/misc/extract-text"
|
||||
|
|
@ -173,6 +174,11 @@ export async function writeToFileTool(
|
|||
return
|
||||
}
|
||||
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
|
||||
|
||||
// Track file edit operation
|
||||
if (relPath) {
|
||||
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
|
||||
}
|
||||
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
|
||||
if (userEdits) {
|
||||
await cline.say(
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ export const GlobalFileNames = {
|
|||
mcpSettings: "mcp_settings.json",
|
||||
unboundModels: "unbound_models.json",
|
||||
customModes: "custom_modes.json",
|
||||
taskMetadata: "task_metadata.json",
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue