فهرست منبع

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 <[email protected]>
Sam Hoang Van 8 ماه پیش
والد
کامیت
5352beb95c

+ 88 - 59
src/core/Cline.ts

@@ -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.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.taskId = historyItem ? historyItem.id : crypto.randomUUID()
-		this.instanceId = crypto.randomUUID().slice(0, 8)
-		this.taskNumber = -1
 		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>")
-
-					if (block.type === "text") {
-						if (shouldProcessMentions(block.text)) {
+		// 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,
+								this.fileContextTracker,
+							),
+						}
+					}
+					return block
+				} else if (block.type === "tool_result") {
+					if (typeof block.content === "string") {
+						if (shouldProcessMentions(block.content)) {
 							return {
 								...block,
-								text: await parseMentions(block.text, this.cwd, this.urlContentFetcher),
+								content: await parseMentions(
+									block.content,
+									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,
-											),
-										}
+					} 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 contentBlock
+							}),
+						)
+						return {
+							...block,
+							content: parsedContent,
 						}
-						return block
 					}
 					return block
-				}),
-			),
-			this.getEnvironmentDetails(includeFileDetails),
-		])
+				}
+				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
+	}
 }

+ 12 - 0
src/core/__tests__/Cline.test.ts

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

+ 3 - 0
src/core/__tests__/read-file-maxReadFileLine.test.ts

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

+ 4 - 0
src/core/__tests__/read-file-xml.test.ts

@@ -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 - 0
src/core/context-tracking/FileContextTracker.ts

@@ -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 - 0
src/core/context-tracking/FileContextTrackerTypes.ts

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

+ 11 - 1
src/core/mentions/index.ts

@@ -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("/")) {

+ 5 - 0
src/core/tools/applyDiffTool.ts

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

+ 6 - 0
src/core/tools/insertContentTool.ts

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

+ 4 - 0
src/core/tools/listCodeDefinitionNamesTool.ts

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

+ 6 - 0
src/core/tools/readFileTool.ts

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

+ 5 - 0
src/core/tools/searchAndReplaceTool.ts

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

+ 6 - 0
src/core/tools/writeToFileTool.ts

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

+ 1 - 0
src/shared/globalFileNames.ts

@@ -7,4 +7,5 @@ export const GlobalFileNames = {
 	mcpSettings: "mcp_settings.json",
 	unboundModels: "unbound_models.json",
 	customModes: "custom_modes.json",
+	taskMetadata: "task_metadata.json",
 }