-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathFileContextTracker.ts
More file actions
280 lines (242 loc) · 9.55 KB
/
Copy pathFileContextTracker.ts
File metadata and controls
280 lines (242 loc) · 9.55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
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
}
const 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()
}
}