mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: implement environment details caching to reduce duplicate processing
- Add EnvironmentDetailsCache class with section-based hashing - Implement change detection for VSCode files, tabs, terminals, cost, mode - Add configurable TTL for different section types - Modify getEnvironmentDetails to use caching for performance optimization - Add comprehensive test suite for caching mechanism - Maintain backward compatibility with existing API Fixes #5844
This commit is contained in:
parent
a7771a3c11
commit
bcd29e385d
3 changed files with 1113 additions and 204 deletions
621
src/core/environment/EnvironmentDetailsCache.ts
Normal file
621
src/core/environment/EnvironmentDetailsCache.ts
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
import crypto from "crypto"
|
||||
import * as vscode from "vscode"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
import { Task } from "../task/Task"
|
||||
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
|
||||
import { Terminal } from "../../integrations/terminal/Terminal"
|
||||
import { getApiMetrics } from "../../shared/getApiMetrics"
|
||||
import { defaultModeSlug, getFullModeDetails } from "../../shared/modes"
|
||||
import { formatLanguage } from "../../shared/language"
|
||||
import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments"
|
||||
import { listFiles } from "../../services/glob/list-files"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
/**
|
||||
* Represents a cached section of environment details
|
||||
*/
|
||||
interface CachedSection {
|
||||
hash: string
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for environment details sections
|
||||
*/
|
||||
interface SectionConfig {
|
||||
/** Whether this section should be cached */
|
||||
cacheable: boolean
|
||||
/** TTL in milliseconds (0 = no expiration) */
|
||||
ttl: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment details cache manager that implements change detection
|
||||
* to reduce duplicate processing and token usage
|
||||
*/
|
||||
export class EnvironmentDetailsCache {
|
||||
private cache = new Map<string, CachedSection>()
|
||||
private readonly sectionConfigs: Record<string, SectionConfig> = {
|
||||
visibleFiles: { cacheable: true, ttl: 5000 }, // 5 seconds
|
||||
openTabs: { cacheable: true, ttl: 5000 },
|
||||
terminals: { cacheable: true, ttl: 1000 }, // Shorter TTL for dynamic content
|
||||
recentlyModified: { cacheable: true, ttl: 2000 },
|
||||
time: { cacheable: false, ttl: 0 }, // Always fresh
|
||||
cost: { cacheable: true, ttl: 1000 },
|
||||
mode: { cacheable: true, ttl: 10000 }, // 10 seconds
|
||||
fileDetails: { cacheable: true, ttl: 30000 }, // 30 seconds for file listings
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a hash for the given content
|
||||
*/
|
||||
private createHash(content: string): string {
|
||||
return crypto.createHash("sha256").update(content).digest("hex")
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a cached section is still valid
|
||||
*/
|
||||
private isCacheValid(section: CachedSection, config: SectionConfig): boolean {
|
||||
if (config.ttl === 0) return true // No expiration
|
||||
return Date.now() - section.timestamp < config.ttl
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or generates the visible files section
|
||||
*/
|
||||
async getVisibleFilesSection(cline: Task, maxWorkspaceFiles: number): Promise<string> {
|
||||
const sectionKey = "visibleFiles"
|
||||
const config = this.sectionConfigs[sectionKey]
|
||||
|
||||
if (!config.cacheable) {
|
||||
return this.generateVisibleFilesSection(cline, maxWorkspaceFiles)
|
||||
}
|
||||
|
||||
// Create a hash of the inputs that affect this section
|
||||
const visibleFilePaths = vscode.window.visibleTextEditors
|
||||
?.map((editor: vscode.TextEditor) => editor.document?.uri?.fsPath)
|
||||
.filter(Boolean)
|
||||
.map((absolutePath: string) => path.relative(cline.cwd, absolutePath))
|
||||
.slice(0, maxWorkspaceFiles)
|
||||
|
||||
const inputHash = this.createHash(JSON.stringify({
|
||||
visibleFilePaths: visibleFilePaths || [],
|
||||
cwd: cline.cwd,
|
||||
maxWorkspaceFiles
|
||||
}))
|
||||
|
||||
const cached = this.cache.get(sectionKey)
|
||||
if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) {
|
||||
return cached.content
|
||||
}
|
||||
|
||||
// Generate fresh content
|
||||
const content = await this.generateVisibleFilesSection(cline, maxWorkspaceFiles)
|
||||
this.cache.set(sectionKey, {
|
||||
hash: inputHash,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the visible files section content
|
||||
*/
|
||||
private async generateVisibleFilesSection(cline: Task, maxWorkspaceFiles: number): Promise<string> {
|
||||
let details = "\n\n# VSCode Visible Files"
|
||||
|
||||
const visibleFilePaths = vscode.window.visibleTextEditors
|
||||
?.map((editor: vscode.TextEditor) => editor.document?.uri?.fsPath)
|
||||
.filter(Boolean)
|
||||
.map((absolutePath: string) => path.relative(cline.cwd, absolutePath))
|
||||
.slice(0, maxWorkspaceFiles)
|
||||
|
||||
// Filter paths through rooIgnoreController
|
||||
const allowedVisibleFiles = cline.rooIgnoreController
|
||||
? cline.rooIgnoreController.filterPaths(visibleFilePaths || [])
|
||||
: (visibleFilePaths || []).map((p: string) => p.toPosix()).join("\n")
|
||||
|
||||
if (allowedVisibleFiles) {
|
||||
details += `\n${allowedVisibleFiles}`
|
||||
} else {
|
||||
details += "\n(No visible files)"
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or generates the open tabs section
|
||||
*/
|
||||
async getOpenTabsSection(cline: Task, maxTabs: number): Promise<string> {
|
||||
const sectionKey = "openTabs"
|
||||
const config = this.sectionConfigs[sectionKey]
|
||||
|
||||
if (!config.cacheable) {
|
||||
return this.generateOpenTabsSection(cline, maxTabs)
|
||||
}
|
||||
|
||||
// Create a hash of the inputs that affect this section
|
||||
const openTabPaths = vscode.window.tabGroups.all
|
||||
.flatMap((group: vscode.TabGroup) => group.tabs)
|
||||
.map((tab: vscode.Tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
|
||||
.filter(Boolean)
|
||||
.map((absolutePath: string) => path.relative(cline.cwd, absolutePath).toPosix())
|
||||
.slice(0, maxTabs)
|
||||
|
||||
const inputHash = this.createHash(JSON.stringify({
|
||||
openTabPaths,
|
||||
cwd: cline.cwd,
|
||||
maxTabs
|
||||
}))
|
||||
|
||||
const cached = this.cache.get(sectionKey)
|
||||
if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) {
|
||||
return cached.content
|
||||
}
|
||||
|
||||
// Generate fresh content
|
||||
const content = await this.generateOpenTabsSection(cline, maxTabs)
|
||||
this.cache.set(sectionKey, {
|
||||
hash: inputHash,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the open tabs section content
|
||||
*/
|
||||
private async generateOpenTabsSection(cline: Task, maxTabs: number): Promise<string> {
|
||||
let details = "\n\n# VSCode Open Tabs"
|
||||
|
||||
const openTabPaths = vscode.window.tabGroups.all
|
||||
.flatMap((group: vscode.TabGroup) => group.tabs)
|
||||
.map((tab: vscode.Tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
|
||||
.filter(Boolean)
|
||||
.map((absolutePath: string) => path.relative(cline.cwd, absolutePath).toPosix())
|
||||
.slice(0, maxTabs)
|
||||
|
||||
// Filter paths through rooIgnoreController
|
||||
const allowedOpenTabs = cline.rooIgnoreController
|
||||
? cline.rooIgnoreController.filterPaths(openTabPaths)
|
||||
: openTabPaths.map((p: string) => p.toPosix()).join("\n")
|
||||
|
||||
if (allowedOpenTabs) {
|
||||
details += `\n${allowedOpenTabs}`
|
||||
} else {
|
||||
details += "\n(No open tabs)"
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or generates the terminals section
|
||||
*/
|
||||
async getTerminalsSection(cline: Task, terminalOutputLineLimit: number): Promise<string> {
|
||||
const sectionKey = "terminals"
|
||||
const config = this.sectionConfigs[sectionKey]
|
||||
|
||||
if (!config.cacheable) {
|
||||
return this.generateTerminalsSection(cline, terminalOutputLineLimit)
|
||||
}
|
||||
|
||||
// Get terminal state for hashing
|
||||
const busyTerminals = [
|
||||
...TerminalRegistry.getTerminals(true, cline.taskId),
|
||||
...TerminalRegistry.getBackgroundTerminals(true),
|
||||
]
|
||||
|
||||
const inactiveTerminals = [
|
||||
...TerminalRegistry.getTerminals(false, cline.taskId),
|
||||
...TerminalRegistry.getBackgroundTerminals(false),
|
||||
]
|
||||
|
||||
// Create a simplified hash of terminal state
|
||||
const terminalState = {
|
||||
busyCount: busyTerminals.length,
|
||||
inactiveCount: inactiveTerminals.length,
|
||||
busyIds: busyTerminals.map(t => t.id).sort(),
|
||||
inactiveIds: inactiveTerminals.map(t => t.id).sort(),
|
||||
// Include a timestamp component since terminal output changes frequently
|
||||
timeWindow: Math.floor(Date.now() / 1000) // 1-second granularity
|
||||
}
|
||||
|
||||
const inputHash = this.createHash(JSON.stringify(terminalState))
|
||||
|
||||
const cached = this.cache.get(sectionKey)
|
||||
if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) {
|
||||
return cached.content
|
||||
}
|
||||
|
||||
// Generate fresh content
|
||||
const content = await this.generateTerminalsSection(cline, terminalOutputLineLimit)
|
||||
this.cache.set(sectionKey, {
|
||||
hash: inputHash,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the terminals section content
|
||||
*/
|
||||
private async generateTerminalsSection(cline: Task, terminalOutputLineLimit: number): Promise<string> {
|
||||
// Get task-specific and background terminals.
|
||||
const busyTerminals = [
|
||||
...TerminalRegistry.getTerminals(true, cline.taskId),
|
||||
...TerminalRegistry.getBackgroundTerminals(true),
|
||||
]
|
||||
|
||||
const inactiveTerminals = [
|
||||
...TerminalRegistry.getTerminals(false, cline.taskId),
|
||||
...TerminalRegistry.getBackgroundTerminals(false),
|
||||
]
|
||||
|
||||
let terminalDetails = ""
|
||||
|
||||
if (busyTerminals.length > 0) {
|
||||
// Terminals are cool, let's retrieve their output.
|
||||
terminalDetails += "\n\n# Actively Running Terminals"
|
||||
|
||||
for (const busyTerminal of busyTerminals) {
|
||||
const cwd = busyTerminal.getCurrentWorkingDirectory()
|
||||
terminalDetails += `\n## Terminal ${busyTerminal.id} (Active)`
|
||||
terminalDetails += `\n### Working Directory: \`${cwd}\``
|
||||
terminalDetails += `\n### Original command: \`${busyTerminal.getLastCommand()}\``
|
||||
let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id)
|
||||
|
||||
if (newOutput) {
|
||||
newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit)
|
||||
terminalDetails += `\n### New Output\n${newOutput}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First check if any inactive terminals in this task have completed
|
||||
// processes with output.
|
||||
const terminalsWithOutput = inactiveTerminals.filter((terminal) => {
|
||||
const completedProcesses = terminal.getProcessesWithOutput()
|
||||
return completedProcesses.length > 0
|
||||
})
|
||||
|
||||
// Only add the header if there are terminals with output.
|
||||
if (terminalsWithOutput.length > 0) {
|
||||
terminalDetails += "\n\n# Inactive Terminals with Completed Process Output"
|
||||
|
||||
// Process each terminal with output.
|
||||
for (const inactiveTerminal of terminalsWithOutput) {
|
||||
let terminalOutputs: string[] = []
|
||||
|
||||
// Get output from completed processes queue.
|
||||
const completedProcesses = inactiveTerminal.getProcessesWithOutput()
|
||||
|
||||
for (const process of completedProcesses) {
|
||||
let output = process.getUnretrievedOutput()
|
||||
|
||||
if (output) {
|
||||
output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit)
|
||||
terminalOutputs.push(`Command: \`${process.command}\`\n${output}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean the queue after retrieving output.
|
||||
inactiveTerminal.cleanCompletedProcessQueue()
|
||||
|
||||
// Add this terminal's outputs to the details.
|
||||
if (terminalOutputs.length > 0) {
|
||||
const cwd = inactiveTerminal.getCurrentWorkingDirectory()
|
||||
terminalDetails += `\n## Terminal ${inactiveTerminal.id} (Inactive)`
|
||||
terminalDetails += `\n### Working Directory: \`${cwd}\``
|
||||
terminalOutputs.forEach((output) => {
|
||||
terminalDetails += `\n### New Output\n${output}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return terminalDetails
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or generates the recently modified files section
|
||||
*/
|
||||
async getRecentlyModifiedSection(cline: Task): Promise<string> {
|
||||
const sectionKey = "recentlyModified"
|
||||
const config = this.sectionConfigs[sectionKey]
|
||||
|
||||
if (!config.cacheable) {
|
||||
return this.generateRecentlyModifiedSection(cline)
|
||||
}
|
||||
|
||||
// Get recently modified files for hashing
|
||||
const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles()
|
||||
const inputHash = this.createHash(JSON.stringify(recentlyModifiedFiles.sort()))
|
||||
|
||||
const cached = this.cache.get(sectionKey)
|
||||
if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) {
|
||||
// Note: We still need to clear the files even if using cache
|
||||
cline.fileContextTracker.getAndClearRecentlyModifiedFiles()
|
||||
return cached.content
|
||||
}
|
||||
|
||||
// Generate fresh content
|
||||
const content = await this.generateRecentlyModifiedSection(cline)
|
||||
this.cache.set(sectionKey, {
|
||||
hash: inputHash,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the recently modified files section content
|
||||
*/
|
||||
private async generateRecentlyModifiedSection(cline: Task): Promise<string> {
|
||||
const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles()
|
||||
|
||||
if (recentlyModifiedFiles.length > 0) {
|
||||
let 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}`
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current time section (always fresh)
|
||||
*/
|
||||
getTimeSection(): string {
|
||||
const now = new Date()
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const timeZoneOffset = -now.getTimezoneOffset() / 60
|
||||
const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset))
|
||||
const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60))
|
||||
const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}`
|
||||
|
||||
return `\n\n# Current Time\n${now.toISOString()} (UTC, UTC${timeZoneOffsetStr})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or generates the cost section
|
||||
*/
|
||||
async getCostSection(cline: Task): Promise<string> {
|
||||
const sectionKey = "cost"
|
||||
const config = this.sectionConfigs[sectionKey]
|
||||
|
||||
if (!config.cacheable) {
|
||||
return this.generateCostSection(cline)
|
||||
}
|
||||
|
||||
// Get cost metrics for hashing
|
||||
const { totalCost } = getApiMetrics(cline.clineMessages)
|
||||
const inputHash = this.createHash(JSON.stringify({ totalCost }))
|
||||
|
||||
const cached = this.cache.get(sectionKey)
|
||||
if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) {
|
||||
return cached.content
|
||||
}
|
||||
|
||||
// Generate fresh content
|
||||
const content = await this.generateCostSection(cline)
|
||||
this.cache.set(sectionKey, {
|
||||
hash: inputHash,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the cost section content
|
||||
*/
|
||||
private async generateCostSection(cline: Task): Promise<string> {
|
||||
const { totalCost } = getApiMetrics(cline.clineMessages)
|
||||
return `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or generates the mode section
|
||||
*/
|
||||
async getModeSection(cline: Task): Promise<string> {
|
||||
const sectionKey = "mode"
|
||||
const config = this.sectionConfigs[sectionKey]
|
||||
|
||||
if (!config.cacheable) {
|
||||
return this.generateModeSection(cline)
|
||||
}
|
||||
|
||||
// Get state for hashing
|
||||
const clineProvider = cline.providerRef.deref()
|
||||
const state = await clineProvider?.getState()
|
||||
const {
|
||||
mode,
|
||||
customModes,
|
||||
customModePrompts,
|
||||
experiments = {},
|
||||
customInstructions: globalCustomInstructions,
|
||||
language,
|
||||
} = state ?? {}
|
||||
|
||||
const currentMode = mode ?? defaultModeSlug
|
||||
const { id: modelId } = cline.api.getModel()
|
||||
|
||||
const inputHash = this.createHash(JSON.stringify({
|
||||
currentMode,
|
||||
modelId,
|
||||
customModes,
|
||||
customModePrompts,
|
||||
experiments,
|
||||
globalCustomInstructions,
|
||||
language
|
||||
}))
|
||||
|
||||
const cached = this.cache.get(sectionKey)
|
||||
if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) {
|
||||
return cached.content
|
||||
}
|
||||
|
||||
// Generate fresh content
|
||||
const content = await this.generateModeSection(cline)
|
||||
this.cache.set(sectionKey, {
|
||||
hash: inputHash,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the mode section content
|
||||
*/
|
||||
private async generateModeSection(cline: Task): Promise<string> {
|
||||
const clineProvider = cline.providerRef.deref()
|
||||
const state = await clineProvider?.getState()
|
||||
const {
|
||||
mode,
|
||||
customModes,
|
||||
customModePrompts,
|
||||
experiments = {},
|
||||
customInstructions: globalCustomInstructions,
|
||||
language,
|
||||
} = state ?? {}
|
||||
|
||||
const currentMode = mode ?? defaultModeSlug
|
||||
const { id: modelId } = cline.api.getModel()
|
||||
|
||||
const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
|
||||
cwd: cline.cwd,
|
||||
globalCustomInstructions,
|
||||
language: language ?? formatLanguage(vscode.env.language),
|
||||
})
|
||||
|
||||
let details = `\n\n# Current Mode\n`
|
||||
details += `<slug>${currentMode}</slug>\n`
|
||||
details += `<name>${modeDetails.name}</name>\n`
|
||||
details += `<model>${modelId}</model>\n`
|
||||
|
||||
if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) {
|
||||
details += `<role>${modeDetails.roleDefinition}</role>\n`
|
||||
|
||||
if (modeDetails.customInstructions) {
|
||||
details += `<custom_instructions>${modeDetails.customInstructions}</custom_instructions>\n`
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or generates the file details section
|
||||
*/
|
||||
async getFileDetailsSection(cline: Task, maxWorkspaceFiles: number): Promise<string> {
|
||||
const sectionKey = "fileDetails"
|
||||
const config = this.sectionConfigs[sectionKey]
|
||||
|
||||
if (!config.cacheable) {
|
||||
return this.generateFileDetailsSection(cline, maxWorkspaceFiles)
|
||||
}
|
||||
|
||||
// Create hash based on workspace directory and settings
|
||||
const clineProvider = cline.providerRef.deref()
|
||||
const state = await clineProvider?.getState()
|
||||
const { showRooIgnoredFiles = true } = state ?? {}
|
||||
|
||||
const inputHash = this.createHash(JSON.stringify({
|
||||
cwd: cline.cwd,
|
||||
maxWorkspaceFiles,
|
||||
showRooIgnoredFiles,
|
||||
// Add a time component to periodically refresh file listings
|
||||
timeWindow: Math.floor(Date.now() / 30000) // 30-second windows
|
||||
}))
|
||||
|
||||
const cached = this.cache.get(sectionKey)
|
||||
if (cached && cached.hash === inputHash && this.isCacheValid(cached, config)) {
|
||||
return cached.content
|
||||
}
|
||||
|
||||
// Generate fresh content
|
||||
const content = await this.generateFileDetailsSection(cline, maxWorkspaceFiles)
|
||||
this.cache.set(sectionKey, {
|
||||
hash: inputHash,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the file details section content
|
||||
*/
|
||||
private async generateFileDetailsSection(cline: Task, maxWorkspaceFiles: number): Promise<string> {
|
||||
let details = `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n`
|
||||
const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop"))
|
||||
|
||||
if (isDesktop) {
|
||||
// Don't want to immediately access desktop since it would show
|
||||
// permission popup.
|
||||
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)"
|
||||
} else {
|
||||
const maxFiles = maxWorkspaceFiles ?? 200
|
||||
|
||||
// Early return for limit of 0
|
||||
if (maxFiles === 0) {
|
||||
details += "(Workspace files context disabled. Use list_files to explore if needed.)"
|
||||
} else {
|
||||
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
|
||||
const clineProvider = cline.providerRef.deref()
|
||||
const state = await clineProvider?.getState()
|
||||
const { showRooIgnoredFiles = true } = state ?? {}
|
||||
|
||||
const result = formatResponse.formatFilesList(
|
||||
cline.cwd,
|
||||
files,
|
||||
didHitLimit,
|
||||
cline.rooIgnoreController,
|
||||
showRooIgnoredFiles,
|
||||
)
|
||||
|
||||
details += result
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cache (useful for testing or manual refresh)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cache statistics for debugging
|
||||
*/
|
||||
getCacheStats(): { size: number; sections: string[] } {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
sections: Array.from(this.cache.keys())
|
||||
}
|
||||
}
|
||||
}
|
||||
452
src/core/environment/__tests__/EnvironmentDetailsCache.spec.ts
Normal file
452
src/core/environment/__tests__/EnvironmentDetailsCache.spec.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
// npx vitest core/environment/__tests__/EnvironmentDetailsCache.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import { EnvironmentDetailsCache } from "../EnvironmentDetailsCache"
|
||||
import { Task } from "../../task/Task"
|
||||
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
|
||||
import { Terminal } from "../../../integrations/terminal/Terminal"
|
||||
import { getApiMetrics } from "../../../shared/getApiMetrics"
|
||||
import { getFullModeDetails } from "../../../shared/modes"
|
||||
import { listFiles } from "../../../services/glob/list-files"
|
||||
import { formatResponse } from "../../prompts/responses"
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
visibleTextEditors: [],
|
||||
tabGroups: { all: [] },
|
||||
},
|
||||
env: {
|
||||
language: "en-US",
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../../integrations/terminal/TerminalRegistry")
|
||||
vi.mock("../../../integrations/terminal/Terminal")
|
||||
vi.mock("../../../shared/getApiMetrics")
|
||||
vi.mock("../../../shared/modes")
|
||||
vi.mock("../../../services/glob/list-files")
|
||||
vi.mock("../../prompts/responses")
|
||||
|
||||
describe("EnvironmentDetailsCache", () => {
|
||||
let cache: EnvironmentDetailsCache
|
||||
let mockTask: Partial<Task>
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new EnvironmentDetailsCache()
|
||||
|
||||
// Mock task
|
||||
mockTask = {
|
||||
cwd: "/test/workspace",
|
||||
taskId: "test-task-id",
|
||||
rooIgnoreController: {
|
||||
filterPaths: vi.fn((paths: string[]) => paths.join("\n")),
|
||||
},
|
||||
fileContextTracker: {
|
||||
getAndClearRecentlyModifiedFiles: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
clineMessages: [],
|
||||
api: {
|
||||
getModel: vi.fn().mockReturnValue({ id: "test-model" }),
|
||||
},
|
||||
providerRef: {
|
||||
deref: vi.fn().mockReturnValue({
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
maxWorkspaceFiles: 200,
|
||||
maxOpenTabsContext: 20,
|
||||
showRooIgnoredFiles: true,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
// Mock external functions
|
||||
vi.mocked(TerminalRegistry.getTerminals).mockReturnValue([])
|
||||
vi.mocked(TerminalRegistry.getBackgroundTerminals).mockReturnValue([])
|
||||
vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 })
|
||||
vi.mocked(getFullModeDetails).mockResolvedValue({
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role",
|
||||
customInstructions: "Test instructions",
|
||||
})
|
||||
vi.mocked(listFiles).mockResolvedValue([["file1.ts", "file2.ts"], false])
|
||||
vi.mocked(formatResponse.formatFilesList).mockReturnValue("file1.ts\nfile2.ts")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("getVisibleFilesSection", () => {
|
||||
it("should generate visible files section", async () => {
|
||||
// Mock VSCode visible editors
|
||||
vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([
|
||||
{
|
||||
document: {
|
||||
uri: { fsPath: "/test/workspace/file1.ts" },
|
||||
},
|
||||
},
|
||||
{
|
||||
document: {
|
||||
uri: { fsPath: "/test/workspace/file2.ts" },
|
||||
},
|
||||
},
|
||||
] as any)
|
||||
|
||||
const result = await cache.getVisibleFilesSection(mockTask as Task, 200)
|
||||
|
||||
expect(result).toContain("# VSCode Visible Files")
|
||||
expect(result).toContain("file1.ts")
|
||||
expect(result).toContain("file2.ts")
|
||||
})
|
||||
|
||||
it("should cache visible files section when inputs are the same", async () => {
|
||||
// Mock VSCode visible editors
|
||||
vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([
|
||||
{
|
||||
document: {
|
||||
uri: { fsPath: "/test/workspace/file1.ts" },
|
||||
},
|
||||
},
|
||||
] as any)
|
||||
|
||||
// First call
|
||||
const result1 = await cache.getVisibleFilesSection(mockTask as Task, 200)
|
||||
|
||||
// Second call with same inputs
|
||||
const result2 = await cache.getVisibleFilesSection(mockTask as Task, 200)
|
||||
|
||||
expect(result1).toBe(result2) // Should return exact same string (cached)
|
||||
expect(mockTask.rooIgnoreController?.filterPaths).toHaveBeenCalledTimes(1) // Only called once
|
||||
})
|
||||
|
||||
it("should regenerate when visible files change", async () => {
|
||||
// First call with one file
|
||||
vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([
|
||||
{
|
||||
document: {
|
||||
uri: { fsPath: "/test/workspace/file1.ts" },
|
||||
},
|
||||
},
|
||||
] as any)
|
||||
|
||||
const result1 = await cache.getVisibleFilesSection(mockTask as Task, 200)
|
||||
|
||||
// Second call with different files
|
||||
vi.mocked(vscode.window.visibleTextEditors).mockReturnValue([
|
||||
{
|
||||
document: {
|
||||
uri: { fsPath: "/test/workspace/file2.ts" },
|
||||
},
|
||||
},
|
||||
] as any)
|
||||
|
||||
const result2 = await cache.getVisibleFilesSection(mockTask as Task, 200)
|
||||
|
||||
expect(result1).not.toBe(result2) // Should be different
|
||||
expect(result2).toContain("file2.ts")
|
||||
expect(result2).not.toContain("file1.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenTabsSection", () => {
|
||||
it("should generate open tabs section", async () => {
|
||||
// Mock VSCode tab groups
|
||||
vi.mocked(vscode.window.tabGroups).all = [
|
||||
{
|
||||
tabs: [
|
||||
{
|
||||
input: {
|
||||
uri: { fsPath: "/test/workspace/tab1.ts" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
uri: { fsPath: "/test/workspace/tab2.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const result = await cache.getOpenTabsSection(mockTask as Task, 20)
|
||||
|
||||
expect(result).toContain("# VSCode Open Tabs")
|
||||
expect(result).toContain("tab1.ts")
|
||||
expect(result).toContain("tab2.ts")
|
||||
})
|
||||
|
||||
it("should cache open tabs section when inputs are the same", async () => {
|
||||
// Mock VSCode tab groups
|
||||
vi.mocked(vscode.window.tabGroups).all = [
|
||||
{
|
||||
tabs: [
|
||||
{
|
||||
input: {
|
||||
uri: { fsPath: "/test/workspace/tab1.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
// First call
|
||||
const result1 = await cache.getOpenTabsSection(mockTask as Task, 20)
|
||||
|
||||
// Second call with same inputs
|
||||
const result2 = await cache.getOpenTabsSection(mockTask as Task, 20)
|
||||
|
||||
expect(result1).toBe(result2) // Should return exact same string (cached)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTerminalsSection", () => {
|
||||
it("should generate terminals section with active terminals", async () => {
|
||||
const mockTerminal = {
|
||||
id: "terminal-1",
|
||||
getCurrentWorkingDirectory: vi.fn().mockReturnValue("/test/workspace"),
|
||||
getLastCommand: vi.fn().mockReturnValue("npm test"),
|
||||
}
|
||||
|
||||
vi.mocked(TerminalRegistry.getTerminals).mockReturnValue([mockTerminal] as any)
|
||||
vi.mocked(TerminalRegistry.getUnretrievedOutput).mockReturnValue("Test output")
|
||||
vi.mocked(Terminal.compressTerminalOutput).mockReturnValue("Compressed output")
|
||||
|
||||
const result = await cache.getTerminalsSection(mockTask as Task, 500)
|
||||
|
||||
expect(result).toContain("# Actively Running Terminals")
|
||||
expect(result).toContain("Terminal terminal-1 (Active)")
|
||||
expect(result).toContain("npm test")
|
||||
expect(result).toContain("Compressed output")
|
||||
})
|
||||
|
||||
it("should handle inactive terminals with completed processes", async () => {
|
||||
const mockProcess = {
|
||||
command: "npm build",
|
||||
getUnretrievedOutput: vi.fn().mockReturnValue("Build output"),
|
||||
}
|
||||
|
||||
const mockInactiveTerminal = {
|
||||
id: "terminal-2",
|
||||
getCurrentWorkingDirectory: vi.fn().mockReturnValue("/test/workspace"),
|
||||
getProcessesWithOutput: vi.fn().mockReturnValue([mockProcess]),
|
||||
cleanCompletedProcessQueue: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mocked(TerminalRegistry.getTerminals).mockImplementation((active: boolean) =>
|
||||
active ? [] : [mockInactiveTerminal] as any
|
||||
)
|
||||
vi.mocked(Terminal.compressTerminalOutput).mockReturnValue("Compressed build output")
|
||||
|
||||
const result = await cache.getTerminalsSection(mockTask as Task, 500)
|
||||
|
||||
expect(result).toContain("# Inactive Terminals with Completed Process Output")
|
||||
expect(result).toContain("Terminal terminal-2 (Inactive)")
|
||||
expect(result).toContain("npm build")
|
||||
expect(result).toContain("Compressed build output")
|
||||
expect(mockInactiveTerminal.cleanCompletedProcessQueue).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return empty string when no terminals", async () => {
|
||||
vi.mocked(TerminalRegistry.getTerminals).mockReturnValue([])
|
||||
vi.mocked(TerminalRegistry.getBackgroundTerminals).mockReturnValue([])
|
||||
|
||||
const result = await cache.getTerminalsSection(mockTask as Task, 500)
|
||||
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRecentlyModifiedSection", () => {
|
||||
it("should generate recently modified files section", async () => {
|
||||
vi.mocked(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles).mockReturnValue([
|
||||
"modified1.ts",
|
||||
"modified2.ts",
|
||||
])
|
||||
|
||||
const result = await cache.getRecentlyModifiedSection(mockTask as Task)
|
||||
|
||||
expect(result).toContain("# Recently Modified Files")
|
||||
expect(result).toContain("modified1.ts")
|
||||
expect(result).toContain("modified2.ts")
|
||||
})
|
||||
|
||||
it("should return empty string when no recently modified files", async () => {
|
||||
vi.mocked(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles).mockReturnValue([])
|
||||
|
||||
const result = await cache.getRecentlyModifiedSection(mockTask as Task)
|
||||
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should cache based on file list", async () => {
|
||||
// Mock the same file list for both calls
|
||||
vi.mocked(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles)
|
||||
.mockReturnValueOnce(["file1.ts"])
|
||||
.mockReturnValueOnce(["file1.ts"])
|
||||
|
||||
const result1 = await cache.getRecentlyModifiedSection(mockTask as Task)
|
||||
const result2 = await cache.getRecentlyModifiedSection(mockTask as Task)
|
||||
|
||||
// Note: Even with caching, the method still needs to clear the files
|
||||
expect(mockTask.fileContextTracker!.getAndClearRecentlyModifiedFiles).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTimeSection", () => {
|
||||
it("should generate current time section", () => {
|
||||
const result = cache.getTimeSection()
|
||||
|
||||
expect(result).toContain("# Current Time")
|
||||
expect(result).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) // ISO format
|
||||
expect(result).toContain("UTC")
|
||||
})
|
||||
|
||||
it("should always return fresh time (not cached)", () => {
|
||||
const result1 = cache.getTimeSection()
|
||||
|
||||
// Small delay to ensure different timestamps
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 2) {
|
||||
// Wait for at least 2ms
|
||||
}
|
||||
|
||||
const result2 = cache.getTimeSection()
|
||||
|
||||
// Times should be different (not cached)
|
||||
expect(result1).not.toBe(result2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCostSection", () => {
|
||||
it("should generate cost section", async () => {
|
||||
vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 })
|
||||
|
||||
const result = await cache.getCostSection(mockTask as Task)
|
||||
|
||||
expect(result).toContain("# Current Cost")
|
||||
expect(result).toContain("$0.05")
|
||||
})
|
||||
|
||||
it("should handle null cost", async () => {
|
||||
vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: null })
|
||||
|
||||
const result = await cache.getCostSection(mockTask as Task)
|
||||
|
||||
expect(result).toContain("# Current Cost")
|
||||
expect(result).toContain("(Not available)")
|
||||
})
|
||||
|
||||
it("should cache cost section when cost is the same", async () => {
|
||||
vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 })
|
||||
|
||||
const result1 = await cache.getCostSection(mockTask as Task)
|
||||
const result2 = await cache.getCostSection(mockTask as Task)
|
||||
|
||||
expect(result1).toBe(result2) // Should be cached
|
||||
expect(getApiMetrics).toHaveBeenCalledTimes(1) // Only called once for hashing
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModeSection", () => {
|
||||
it("should generate mode section", async () => {
|
||||
const result = await cache.getModeSection(mockTask as Task)
|
||||
|
||||
expect(result).toContain("# Current Mode")
|
||||
expect(result).toContain("<slug>")
|
||||
expect(result).toContain("<name>Test Mode</name>")
|
||||
expect(result).toContain("<model>test-model</model>")
|
||||
})
|
||||
|
||||
it("should cache mode section when inputs are the same", async () => {
|
||||
const result1 = await cache.getModeSection(mockTask as Task)
|
||||
const result2 = await cache.getModeSection(mockTask as Task)
|
||||
|
||||
expect(result1).toBe(result2) // Should be cached
|
||||
expect(getFullModeDetails).toHaveBeenCalledTimes(1) // Only called once
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFileDetailsSection", () => {
|
||||
it("should generate file details section", async () => {
|
||||
const result = await cache.getFileDetailsSection(mockTask as Task, 200)
|
||||
|
||||
expect(result).toContain("# Current Workspace Directory")
|
||||
expect(result).toContain("Files")
|
||||
expect(listFiles).toHaveBeenCalledWith("/test/workspace", true, 200)
|
||||
expect(formatResponse.formatFilesList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle desktop directory specially", async () => {
|
||||
const desktopTask = {
|
||||
...mockTask,
|
||||
cwd: "/home/user/Desktop", // Mock desktop path
|
||||
}
|
||||
|
||||
const result = await cache.getFileDetailsSection(desktopTask as Task, 200)
|
||||
|
||||
expect(result).toContain("Desktop files not shown automatically")
|
||||
expect(listFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle maxWorkspaceFiles = 0", async () => {
|
||||
const result = await cache.getFileDetailsSection(mockTask as Task, 0)
|
||||
|
||||
expect(result).toContain("Workspace files context disabled")
|
||||
expect(listFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("cache management", () => {
|
||||
it("should clear cache", () => {
|
||||
// Add some cached data first
|
||||
cache.getTimeSection() // This doesn't cache, but let's add a cacheable section
|
||||
|
||||
const statsBefore = cache.getCacheStats()
|
||||
cache.clearCache()
|
||||
const statsAfter = cache.getCacheStats()
|
||||
|
||||
expect(statsAfter.size).toBe(0)
|
||||
expect(statsAfter.sections).toEqual([])
|
||||
})
|
||||
|
||||
it("should provide cache statistics", async () => {
|
||||
// Generate some cached sections
|
||||
await cache.getVisibleFilesSection(mockTask as Task, 200)
|
||||
await cache.getCostSection(mockTask as Task)
|
||||
|
||||
const stats = cache.getCacheStats()
|
||||
|
||||
expect(stats.size).toBeGreaterThan(0)
|
||||
expect(stats.sections).toContain("visibleFiles")
|
||||
expect(stats.sections).toContain("cost")
|
||||
})
|
||||
})
|
||||
|
||||
describe("TTL (Time To Live) behavior", () => {
|
||||
it("should respect TTL for cached sections", async () => {
|
||||
// Create a cache with very short TTL for testing
|
||||
const shortTtlCache = new EnvironmentDetailsCache()
|
||||
|
||||
// Mock a section that would normally be cached
|
||||
vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.05 })
|
||||
|
||||
const result1 = await shortTtlCache.getCostSection(mockTask as Task)
|
||||
|
||||
// Immediately get again - should be cached
|
||||
const result2 = await shortTtlCache.getCostSection(mockTask as Task)
|
||||
expect(result1).toBe(result2)
|
||||
|
||||
// Change the cost and verify it eventually updates
|
||||
vi.mocked(getApiMetrics).mockReturnValue({ contextTokens: 1000, totalCost: 0.10 })
|
||||
|
||||
// Wait for TTL to expire (we can't easily test this without mocking time)
|
||||
// For now, just verify the cache can be cleared manually
|
||||
shortTtlCache.clearCache()
|
||||
const result3 = await shortTtlCache.getCostSection(mockTask as Task)
|
||||
expect(result3).toContain("$0.10")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -19,67 +19,23 @@ import { formatResponse } from "../prompts/responses"
|
|||
|
||||
import { Task } from "../task/Task"
|
||||
import { formatReminderSection } from "./reminder"
|
||||
import { EnvironmentDetailsCache } from "./EnvironmentDetailsCache"
|
||||
|
||||
// Global cache instance - shared across all tasks for efficiency
|
||||
const environmentCache = new EnvironmentDetailsCache()
|
||||
|
||||
export async function getEnvironmentDetails(cline: Task, includeFileDetails: boolean = false) {
|
||||
let details = ""
|
||||
|
||||
const clineProvider = cline.providerRef.deref()
|
||||
const state = await clineProvider?.getState()
|
||||
const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = state ?? {}
|
||||
|
||||
// It could be useful for cline to know if the user went from one or no
|
||||
// file to another between messages, so we always include this context.
|
||||
details += "\n\n# VSCode Visible Files"
|
||||
|
||||
const visibleFilePaths = vscode.window.visibleTextEditors
|
||||
?.map((editor) => editor.document?.uri?.fsPath)
|
||||
.filter(Boolean)
|
||||
.map((absolutePath) => path.relative(cline.cwd, absolutePath))
|
||||
.slice(0, maxWorkspaceFiles)
|
||||
|
||||
// Filter paths through rooIgnoreController
|
||||
const allowedVisibleFiles = cline.rooIgnoreController
|
||||
? cline.rooIgnoreController.filterPaths(visibleFilePaths)
|
||||
: visibleFilePaths.map((p) => p.toPosix()).join("\n")
|
||||
|
||||
if (allowedVisibleFiles) {
|
||||
details += `\n${allowedVisibleFiles}`
|
||||
} else {
|
||||
details += "\n(No visible files)"
|
||||
}
|
||||
|
||||
details += "\n\n# VSCode Open Tabs"
|
||||
const { maxOpenTabsContext } = state ?? {}
|
||||
const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200, maxOpenTabsContext } = state ?? {}
|
||||
const maxTabs = maxOpenTabsContext ?? 20
|
||||
const openTabPaths = vscode.window.tabGroups.all
|
||||
.flatMap((group) => group.tabs)
|
||||
.map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
|
||||
.filter(Boolean)
|
||||
.map((absolutePath) => path.relative(cline.cwd, absolutePath).toPosix())
|
||||
.slice(0, maxTabs)
|
||||
|
||||
// Filter paths through rooIgnoreController
|
||||
const allowedOpenTabs = cline.rooIgnoreController
|
||||
? cline.rooIgnoreController.filterPaths(openTabPaths)
|
||||
: openTabPaths.map((p) => p.toPosix()).join("\n")
|
||||
|
||||
if (allowedOpenTabs) {
|
||||
details += `\n${allowedOpenTabs}`
|
||||
} else {
|
||||
details += "\n(No open tabs)"
|
||||
}
|
||||
|
||||
// Get task-specific and background terminals.
|
||||
// Handle terminal waiting logic (this needs to happen before caching)
|
||||
const busyTerminals = [
|
||||
...TerminalRegistry.getTerminals(true, cline.taskId),
|
||||
...TerminalRegistry.getBackgroundTerminals(true),
|
||||
]
|
||||
|
||||
const inactiveTerminals = [
|
||||
...TerminalRegistry.getTerminals(false, cline.taskId),
|
||||
...TerminalRegistry.getBackgroundTerminals(false),
|
||||
]
|
||||
|
||||
if (busyTerminals.length > 0) {
|
||||
if (cline.didEditFile) {
|
||||
await delay(300) // Delay after saving file to let terminals catch up.
|
||||
|
|
@ -95,165 +51,45 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
|
|||
// Reset, this lets us know when to wait for saved files to update terminals.
|
||||
cline.didEditFile = false
|
||||
|
||||
// Waiting for updated diagnostics lets terminal output be the most
|
||||
// up-to-date possible.
|
||||
let terminalDetails = ""
|
||||
// Use cached sections where possible
|
||||
const visibleFilesSection = await environmentCache.getVisibleFilesSection(cline, maxWorkspaceFiles)
|
||||
const openTabsSection = await environmentCache.getOpenTabsSection(cline, maxTabs)
|
||||
const terminalsSection = await environmentCache.getTerminalsSection(cline, terminalOutputLineLimit)
|
||||
const recentlyModifiedSection = await environmentCache.getRecentlyModifiedSection(cline)
|
||||
const timeSection = environmentCache.getTimeSection() // Always fresh
|
||||
const costSection = await environmentCache.getCostSection(cline)
|
||||
const modeSection = await environmentCache.getModeSection(cline)
|
||||
|
||||
if (busyTerminals.length > 0) {
|
||||
// Terminals are cool, let's retrieve their output.
|
||||
terminalDetails += "\n\n# Actively Running Terminals"
|
||||
|
||||
for (const busyTerminal of busyTerminals) {
|
||||
const cwd = busyTerminal.getCurrentWorkingDirectory()
|
||||
terminalDetails += `\n## Terminal ${busyTerminal.id} (Active)`
|
||||
terminalDetails += `\n### Working Directory: \`${cwd}\``
|
||||
terminalDetails += `\n### Original command: \`${busyTerminal.getLastCommand()}\``
|
||||
let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id)
|
||||
|
||||
if (newOutput) {
|
||||
newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit)
|
||||
terminalDetails += `\n### New Output\n${newOutput}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First check if any inactive terminals in this task have completed
|
||||
// processes with output.
|
||||
const terminalsWithOutput = inactiveTerminals.filter((terminal) => {
|
||||
const completedProcesses = terminal.getProcessesWithOutput()
|
||||
return completedProcesses.length > 0
|
||||
})
|
||||
|
||||
// Only add the header if there are terminals with output.
|
||||
if (terminalsWithOutput.length > 0) {
|
||||
terminalDetails += "\n\n# Inactive Terminals with Completed Process Output"
|
||||
|
||||
// Process each terminal with output.
|
||||
for (const inactiveTerminal of terminalsWithOutput) {
|
||||
let terminalOutputs: string[] = []
|
||||
|
||||
// Get output from completed processes queue.
|
||||
const completedProcesses = inactiveTerminal.getProcessesWithOutput()
|
||||
|
||||
for (const process of completedProcesses) {
|
||||
let output = process.getUnretrievedOutput()
|
||||
|
||||
if (output) {
|
||||
output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit)
|
||||
terminalOutputs.push(`Command: \`${process.command}\`\n${output}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean the queue after retrieving output.
|
||||
inactiveTerminal.cleanCompletedProcessQueue()
|
||||
|
||||
// Add this terminal's outputs to the details.
|
||||
if (terminalOutputs.length > 0) {
|
||||
const cwd = inactiveTerminal.getCurrentWorkingDirectory()
|
||||
terminalDetails += `\n## Terminal ${inactiveTerminal.id} (Inactive)`
|
||||
terminalDetails += `\n### Working Directory: \`${cwd}\``
|
||||
terminalOutputs.forEach((output) => {
|
||||
terminalDetails += `\n### New Output\n${output}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(`[Task#getEnvironmentDetails] terminalDetails: ${terminalDetails}`)
|
||||
|
||||
// Add recently modified files section.
|
||||
const recentlyModifiedFiles = cline.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
|
||||
}
|
||||
|
||||
// Add current time information with timezone.
|
||||
const now = new Date()
|
||||
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation
|
||||
const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset))
|
||||
const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60))
|
||||
const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}`
|
||||
details += `\n\n# Current Time\nCurrent time in ISO 8601 UTC format: ${now.toISOString()}\nUser time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
|
||||
|
||||
// Add context tokens information.
|
||||
const { contextTokens, totalCost } = getApiMetrics(cline.clineMessages)
|
||||
const { id: modelId } = cline.api.getModel()
|
||||
|
||||
details += `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}`
|
||||
|
||||
// Add current mode and any mode-specific warnings.
|
||||
const {
|
||||
mode,
|
||||
customModes,
|
||||
customModePrompts,
|
||||
experiments = {} as Record<ExperimentId, boolean>,
|
||||
customInstructions: globalCustomInstructions,
|
||||
language,
|
||||
} = state ?? {}
|
||||
|
||||
const currentMode = mode ?? defaultModeSlug
|
||||
|
||||
const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
|
||||
cwd: cline.cwd,
|
||||
globalCustomInstructions,
|
||||
language: language ?? formatLanguage(vscode.env.language),
|
||||
})
|
||||
|
||||
details += `\n\n# Current Mode\n`
|
||||
details += `<slug>${currentMode}</slug>\n`
|
||||
details += `<name>${modeDetails.name}</name>\n`
|
||||
details += `<model>${modelId}</model>\n`
|
||||
|
||||
if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) {
|
||||
details += `<role>${modeDetails.roleDefinition}</role>\n`
|
||||
|
||||
if (modeDetails.customInstructions) {
|
||||
details += `<custom_instructions>${modeDetails.customInstructions}</custom_instructions>\n`
|
||||
}
|
||||
}
|
||||
// Combine all sections
|
||||
let details = ""
|
||||
details += visibleFilesSection
|
||||
details += openTabsSection
|
||||
details += terminalsSection
|
||||
details += recentlyModifiedSection
|
||||
details += timeSection
|
||||
details += costSection
|
||||
details += modeSection
|
||||
|
||||
// Handle file details section if requested
|
||||
if (includeFileDetails) {
|
||||
details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n`
|
||||
const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop"))
|
||||
|
||||
if (isDesktop) {
|
||||
// Don't want to immediately access desktop since it would show
|
||||
// permission popup.
|
||||
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)"
|
||||
} else {
|
||||
const maxFiles = maxWorkspaceFiles ?? 200
|
||||
|
||||
// Early return for limit of 0
|
||||
if (maxFiles === 0) {
|
||||
details += "(Workspace files context disabled. Use list_files to explore if needed.)"
|
||||
} else {
|
||||
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
|
||||
const { showRooIgnoredFiles = true } = state ?? {}
|
||||
|
||||
const result = formatResponse.formatFilesList(
|
||||
cline.cwd,
|
||||
files,
|
||||
didHitLimit,
|
||||
cline.rooIgnoreController,
|
||||
showRooIgnoredFiles,
|
||||
)
|
||||
|
||||
details += result
|
||||
}
|
||||
}
|
||||
const fileDetailsSection = await environmentCache.getFileDetailsSection(cline, maxWorkspaceFiles)
|
||||
details += fileDetailsSection
|
||||
}
|
||||
|
||||
const reminderSection = formatReminderSection(cline.todoList)
|
||||
return `<environment_details>\n${details.trim()}\n${reminderSection}\n</environment_details>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the environment details cache (useful for testing or manual refresh)
|
||||
*/
|
||||
export function clearEnvironmentDetailsCache(): void {
|
||||
environmentCache.clearCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cache statistics for debugging
|
||||
*/
|
||||
export function getEnvironmentDetailsCacheStats(): { size: number; sections: string[] } {
|
||||
return environmentCache.getCacheStats()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue