Roo-Code/src/core/context-tracking/FileContextTracker.ts
Hannes Rudolph d748de6fae
feat(condense v2.1): add smart code folding (#10942)
* feat(condense): add smart code folding with tree-sitter signatures

At context condensation time, use tree-sitter to generate folded code
signatures (function definitions, class declarations) for files read
during the conversation. Each file is included as its own <system-reminder>
block in the condensed summary, preserving structural awareness without
consuming excessive tokens.

- Add getFilesReadByRoo() method to FileContextTracker
- Create generateFoldedFileContext() using tree-sitter parsing
- Update summarizeConversation() to accept array of file sections
- Each file gets its own content block in the summary message
- Add comprehensive test coverage (12 tests)

* fix: skip tree-sitter error strings in folded file context

- Add isTreeSitterErrorString helper to detect error messages
- Skip files that return error strings instead of embedding them
- Add test for error string handling

* refactor: move generateFoldedFileContext() inside summarizeConversation()

- Update summarizeConversation() to accept filesReadByRoo, cwd, rooIgnoreController instead of pre-generated sections
- Move folded file context generation inside summarizeConversation() (lines 319-339)
- Update ContextManagementOptions type and manageContext() to pass new parameters
- Remove generateFoldedFileContext from Task.ts imports - folding now handled internally
- Update all tests to use new parameter signature
- Reduces Task.ts complexity by moving folding logic to summarization module

* fix: prioritize most recently read files in folded context

Files are now sorted by roo_read_date descending before folded context
generation, so if the character budget runs out, the most relevant
(recently read) files are included and older files are skipped.

* refactor: improve code quality in condense module

- Convert summarizeConversation to use options object instead of 11 positional params
- Extract duplicated getFilesReadByRoo error handling into helper method
- Remove unnecessary re-export of generateFoldedFileContext
- Update all test files to use new options object pattern

* fix: address roomote feedback - batch error logging and early budget exit

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
2026-01-27 18:47:24 -05:00

280 lines
9.5 KiB
TypeScript

import { safeWriteJson } from "../../utils/safeWriteJson"
import * as path from "path"
import * as vscode from "vscode"
import { getTaskDirectoryPath } from "../../utils/storage"
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 safeWriteJson(filePath, metadata)
} 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)
this.markFileAsEditedByRoo(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
}
/**
* Gets a list of unique file paths that Roo has read during this task.
* Files are sorted by most recently read first, so if there's a character
* budget during folded context generation, the most relevant (recent) files
* are prioritized.
*
* @param sinceTimestamp - Optional timestamp to filter files read after this time
* @returns Array of unique file paths that have been read, most recent first
*/
async getFilesReadByRoo(sinceTimestamp?: number): Promise<string[]> {
try {
const metadata = await this.getTaskMetadata(this.taskId)
const readEntries = metadata.files_in_context.filter((entry) => {
// Only include files that were read by Roo (not user edits)
const isReadByRoo = entry.record_source === "read_tool" || entry.record_source === "file_mentioned"
if (!isReadByRoo) {
return false
}
// If sinceTimestamp is provided, only include files read after that time
if (sinceTimestamp && entry.roo_read_date) {
return entry.roo_read_date >= sinceTimestamp
}
return true
})
// Sort by roo_read_date descending (most recent first)
// Entries without a date go to the end
readEntries.sort((a, b) => {
const dateA = a.roo_read_date ?? 0
const dateB = b.roo_read_date ?? 0
return dateB - dateA
})
// Deduplicate while preserving order (first occurrence = most recent read)
const seen = new Set<string>()
const uniquePaths: string[] = []
for (const entry of readEntries) {
if (!seen.has(entry.path)) {
seen.add(entry.path)
uniquePaths.push(entry.path)
}
}
return uniquePaths
} catch (error) {
console.error("Failed to get files read by Roo:", error)
return []
}
}
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()
}
}