Skip to content

Commit 5352beb

Browse files
samhvw8ellipsis-dev[bot]mrubens
authored
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]>
1 parent 0ddfa4d commit 5352beb

14 files changed

+404
-60
lines changed

src/core/Cline.ts

Lines changed: 88 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { calculateApiCostAnthropic } from "../utils/cost"
5353
import { fileExistsAtPath } from "../utils/fs"
5454
import { arePathsEqual } from "../utils/path"
5555
import { parseMentions } from "./mentions"
56+
import { FileContextTracker } from "./context-tracking/FileContextTracker"
5657
import { RooIgnoreController } from "./ignore/RooIgnoreController"
5758
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
5859
import { formatResponse } from "./prompts/responses"
@@ -130,6 +131,7 @@ export class Cline extends EventEmitter<ClineEvents> {
130131

131132
readonly apiConfiguration: ApiConfiguration
132133
api: ApiHandler
134+
private fileContextTracker: FileContextTracker
133135
private urlContentFetcher: UrlContentFetcher
134136
browserSession: BrowserSession
135137
didEditFile: boolean = false
@@ -201,14 +203,15 @@ export class Cline extends EventEmitter<ClineEvents> {
201203
throw new Error("Either historyItem or task/images must be provided")
202204
}
203205

206+
this.taskId = historyItem ? historyItem.id : crypto.randomUUID()
207+
this.instanceId = crypto.randomUUID().slice(0, 8)
208+
this.taskNumber = -1
209+
204210
this.rooIgnoreController = new RooIgnoreController(this.cwd)
211+
this.fileContextTracker = new FileContextTracker(provider, this.taskId)
205212
this.rooIgnoreController.initialize().catch((error) => {
206213
console.error("Failed to initialize RooIgnoreController:", error)
207214
})
208-
209-
this.taskId = historyItem ? historyItem.id : crypto.randomUUID()
210-
this.instanceId = crypto.randomUUID().slice(0, 8)
211-
this.taskNumber = -1
212215
this.apiConfiguration = apiConfiguration
213216
this.api = buildApiHandler(apiConfiguration)
214217
this.urlContentFetcher = new UrlContentFetcher(provider.context)
@@ -929,6 +932,7 @@ export class Cline extends EventEmitter<ClineEvents> {
929932
this.urlContentFetcher.closeBrowser()
930933
this.browserSession.closeBrowser()
931934
this.rooIgnoreController?.dispose()
935+
this.fileContextTracker.dispose()
932936

933937
// If we're not streaming then `abortStream` (which reverts the diff
934938
// view changes) won't be called, so we need to revert the changes here.
@@ -1322,8 +1326,6 @@ export class Cline extends EventEmitter<ClineEvents> {
13221326

13231327
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
13241328

1325-
let isCheckpointPossible = false
1326-
13271329
switch (block.type) {
13281330
case "text": {
13291331
if (this.didRejectTool || this.didAlreadyUseTool) {
@@ -1460,7 +1462,6 @@ export class Cline extends EventEmitter<ClineEvents> {
14601462

14611463
// Flag a checkpoint as possible since we've used a tool
14621464
// which may have changed the file system.
1463-
isCheckpointPossible = true
14641465
}
14651466

14661467
const askApproval = async (
@@ -1583,6 +1584,7 @@ export class Cline extends EventEmitter<ClineEvents> {
15831584
break
15841585
case "read_file":
15851586
await readFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
1587+
15861588
break
15871589
case "fetch_instructions":
15881590
await fetchInstructionsTool(this, block, askApproval, handleError, pushToolResult)
@@ -1662,7 +1664,9 @@ export class Cline extends EventEmitter<ClineEvents> {
16621664
break
16631665
}
16641666

1665-
if (isCheckpointPossible) {
1667+
const recentlyModifiedFiles = this.fileContextTracker.getAndClearCheckpointPossibleFile()
1668+
if (recentlyModifiedFiles.length > 0) {
1669+
// TODO: we can track what file changes were made and only checkpoint those files, this will be save storage
16661670
this.checkpointSave()
16671671
}
16681672

@@ -1783,18 +1787,17 @@ export class Cline extends EventEmitter<ClineEvents> {
17831787
)
17841788

17851789
const [parsedUserContent, environmentDetails] = await this.loadContext(userContent, includeFileDetails)
1786-
userContent = parsedUserContent
17871790
// add environment details as its own text block, separate from tool results
1788-
userContent.push({ type: "text", text: environmentDetails })
1791+
const finalUserContent = [...parsedUserContent, { type: "text", text: environmentDetails }] as UserContent
17891792

1790-
await this.addToApiConversationHistory({ role: "user", content: userContent })
1793+
await this.addToApiConversationHistory({ role: "user", content: finalUserContent })
17911794
telemetryService.captureConversationMessage(this.taskId, "user")
17921795

17931796
// 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
17941797
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
17951798

17961799
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
1797-
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
1800+
request: finalUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
17981801
} satisfies ClineApiReqInfo)
17991802

18001803
await this.saveClineMessages()
@@ -2045,62 +2048,73 @@ export class Cline extends EventEmitter<ClineEvents> {
20452048
}
20462049

20472050
async loadContext(userContent: UserContent, includeFileDetails: boolean = false) {
2048-
return await Promise.all([
2049-
// Process userContent array, which contains various block types:
2050-
// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
2051-
// We need to apply parseMentions() to:
2052-
// 1. All TextBlockParam's text (first user message with task)
2053-
// 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)
2054-
Promise.all(
2055-
userContent.map(async (block) => {
2056-
const shouldProcessMentions = (text: string) =>
2057-
text.includes("<task>") || text.includes("<feedback>")
2058-
2059-
if (block.type === "text") {
2060-
if (shouldProcessMentions(block.text)) {
2051+
// Process userContent array, which contains various block types:
2052+
// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
2053+
// We need to apply parseMentions() to:
2054+
// 1. All TextBlockParam's text (first user message with task)
2055+
// 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)
2056+
const parsedUserContent = await Promise.all(
2057+
userContent.map(async (block) => {
2058+
const shouldProcessMentions = (text: string) => text.includes("<task>") || text.includes("<feedback>")
2059+
2060+
if (block.type === "text") {
2061+
if (shouldProcessMentions(block.text)) {
2062+
return {
2063+
...block,
2064+
text: await parseMentions(
2065+
block.text,
2066+
this.cwd,
2067+
this.urlContentFetcher,
2068+
this.fileContextTracker,
2069+
),
2070+
}
2071+
}
2072+
return block
2073+
} else if (block.type === "tool_result") {
2074+
if (typeof block.content === "string") {
2075+
if (shouldProcessMentions(block.content)) {
20612076
return {
20622077
...block,
2063-
text: await parseMentions(block.text, this.cwd, this.urlContentFetcher),
2078+
content: await parseMentions(
2079+
block.content,
2080+
this.cwd,
2081+
this.urlContentFetcher,
2082+
this.fileContextTracker,
2083+
),
20642084
}
20652085
}
20662086
return block
2067-
} else if (block.type === "tool_result") {
2068-
if (typeof block.content === "string") {
2069-
if (shouldProcessMentions(block.content)) {
2070-
return {
2071-
...block,
2072-
content: await parseMentions(block.content, this.cwd, this.urlContentFetcher),
2073-
}
2074-
}
2075-
return block
2076-
} else if (Array.isArray(block.content)) {
2077-
const parsedContent = await Promise.all(
2078-
block.content.map(async (contentBlock) => {
2079-
if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
2080-
return {
2081-
...contentBlock,
2082-
text: await parseMentions(
2083-
contentBlock.text,
2084-
this.cwd,
2085-
this.urlContentFetcher,
2086-
),
2087-
}
2087+
} else if (Array.isArray(block.content)) {
2088+
const parsedContent = await Promise.all(
2089+
block.content.map(async (contentBlock) => {
2090+
if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
2091+
return {
2092+
...contentBlock,
2093+
text: await parseMentions(
2094+
contentBlock.text,
2095+
this.cwd,
2096+
this.urlContentFetcher,
2097+
this.fileContextTracker,
2098+
),
20882099
}
2089-
return contentBlock
2090-
}),
2091-
)
2092-
return {
2093-
...block,
2094-
content: parsedContent,
2095-
}
2100+
}
2101+
return contentBlock
2102+
}),
2103+
)
2104+
return {
2105+
...block,
2106+
content: parsedContent,
20962107
}
2097-
return block
20982108
}
20992109
return block
2100-
}),
2101-
),
2102-
this.getEnvironmentDetails(includeFileDetails),
2103-
])
2110+
}
2111+
return block
2112+
}),
2113+
)
2114+
2115+
const environmentDetails = await this.getEnvironmentDetails(includeFileDetails)
2116+
2117+
return [parsedUserContent, environmentDetails]
21042118
}
21052119

21062120
async getEnvironmentDetails(includeFileDetails: boolean = false) {
@@ -2251,6 +2265,16 @@ export class Cline extends EventEmitter<ClineEvents> {
22512265
// details += "\n(No errors detected)"
22522266
// }
22532267

2268+
// Add recently modified files section
2269+
const recentlyModifiedFiles = this.fileContextTracker.getAndClearRecentlyModifiedFiles()
2270+
if (recentlyModifiedFiles.length > 0) {
2271+
details +=
2272+
"\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):"
2273+
for (const filePath of recentlyModifiedFiles) {
2274+
details += `\n${filePath}`
2275+
}
2276+
}
2277+
22542278
if (terminalDetails) {
22552279
details += terminalDetails
22562280
}
@@ -2619,4 +2643,9 @@ export class Cline extends EventEmitter<ClineEvents> {
26192643
this.enableCheckpoints = false
26202644
}
26212645
}
2646+
2647+
// Public accessor for fileContextTracker
2648+
public getFileContextTracker(): FileContextTracker {
2649+
return this.fileContextTracker
2650+
}
26222651
}

src/core/__tests__/Cline.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ import { ApiStreamChunk } from "../../api/transform/stream"
1616
// Mock RooIgnoreController
1717
jest.mock("../ignore/RooIgnoreController")
1818

19+
// Mock storagePathManager to prevent dynamic import issues
20+
jest.mock("../../shared/storagePathManager", () => ({
21+
getTaskDirectoryPath: jest.fn().mockImplementation((globalStoragePath, taskId) => {
22+
return Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)
23+
}),
24+
getSettingsDirectoryPath: jest.fn().mockImplementation((globalStoragePath) => {
25+
return Promise.resolve(`${globalStoragePath}/settings`)
26+
}),
27+
}))
28+
1929
// Mock fileExistsAtPath
2030
jest.mock("../../utils/fs", () => ({
2131
fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
@@ -941,6 +951,7 @@ describe("Cline", () => {
941951
"<task>Text with @/some/path in task tags</task>",
942952
expect.any(String),
943953
expect.any(Object),
954+
expect.any(Object),
944955
)
945956

946957
// Feedback tag content should be processed
@@ -951,6 +962,7 @@ describe("Cline", () => {
951962
"<feedback>Check @/some/path</feedback>",
952963
expect.any(String),
953964
expect.any(Object),
965+
expect.any(Object),
954966
)
955967

956968
// Regular tool result should not be processed

src/core/__tests__/read-file-maxReadFileLine.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ describe("read_file tool with maxReadFileLine setting", () => {
122122
mockCline.say = jest.fn().mockResolvedValue(undefined)
123123
mockCline.ask = jest.fn().mockResolvedValue(true)
124124
mockCline.presentAssistantMessage = jest.fn()
125+
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
126+
trackFileContext: jest.fn().mockResolvedValue(undefined),
127+
})
125128

126129
// Reset tool result
127130
toolResult = undefined

src/core/__tests__/read-file-xml.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ describe("read_file tool XML output structure", () => {
114114
mockCline.ask = jest.fn().mockResolvedValue(true)
115115
mockCline.presentAssistantMessage = jest.fn()
116116
mockCline.sayAndCreateMissingParamError = jest.fn().mockResolvedValue("Missing required parameter")
117+
// Add mock for getFileContextTracker method
118+
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
119+
trackFileContext: jest.fn().mockResolvedValue(undefined),
120+
})
117121

118122
// Reset tool result
119123
toolResult = undefined

0 commit comments

Comments
 (0)