diff --git a/src/core/environment/context/file.ts b/src/core/environment/context/file.ts new file mode 100644 index 0000000000..15b4a13a29 --- /dev/null +++ b/src/core/environment/context/file.ts @@ -0,0 +1,13 @@ +import type { Task } from "../../task/Task" + +export function getFileContext(cline: Task) { + const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles() + if (recentlyModifiedFiles.length > 0) { + return { + recentlyModified: { + file: recentlyModifiedFiles.map((p) => ({ "@path": p })), + }, + } + } + return {} +} diff --git a/src/core/environment/context/metadata.ts b/src/core/environment/context/metadata.ts new file mode 100644 index 0000000000..86dde9772c --- /dev/null +++ b/src/core/environment/context/metadata.ts @@ -0,0 +1,65 @@ +import * as vscode from "vscode" + +import type { ExperimentId } from "@roo-code/types" + +import { EXPERIMENT_IDS, experiments as Experiments } from "../../../shared/experiments" +import { formatLanguage } from "../../../shared/language" +import { defaultModeSlug, getFullModeDetails } from "../../../shared/modes" +import { getApiMetrics } from "../../../shared/getApiMetrics" +import type { Task } from "../../task/Task" + +export async function getMetadataContext(cline: Task) { + const state = await cline.providerRef.deref()?.getState() + const { + mode, + customModes, + customModePrompts, + experiments = {} as Record, + customInstructions: globalCustomInstructions, + language, + } = state ?? {} + + // High-Frequency Metadata + const now = new Date() + const timeZoneOffset = -now.getTimezoneOffset() + const offsetHours = Math.floor(Math.abs(timeZoneOffset) / 60) + const offsetMinutes = Math.abs(timeZoneOffset) % 60 + const offsetSign = timeZoneOffset >= 0 ? "+" : "-" + const offsetString = `${offsetSign}${offsetHours + .toString() + .padStart(2, "0")}:${offsetMinutes.toString().padStart(2, "0")}` + const isoDateWithOffset = now.toISOString().replace(/Z$/, offsetString) + const time = { + "@v": isoDateWithOffset, + } + + const { totalCost } = getApiMetrics(cline.clineMessages) + const cost = { + "@t": totalCost !== null ? totalCost.toFixed(2) : "0.00", + "@c": "USD", + "#text": "Must form responses to minimize cost growth", + } + + // Low-Frequency Metadata + const currentMode = mode ?? defaultModeSlug + const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, { + cwd: cline.cwd, + globalCustomInstructions, + language: language ?? formatLanguage(vscode.env.language), + }) + + const { id: modelId } = cline.api.getModel() + const modeInfo = { + "@slug": currentMode, + "@name": modeDetails.name, + "@model": modelId, + ...(Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING) && { + "@role": modeDetails.roleDefinition, + ...(modeDetails.customInstructions && { + "@customInstructions": modeDetails.customInstructions, + }), + }), + } + + return { time, cost, mode: modeInfo } +} diff --git a/src/core/environment/context/terminal.ts b/src/core/environment/context/terminal.ts new file mode 100644 index 0000000000..a5933ea47c --- /dev/null +++ b/src/core/environment/context/terminal.ts @@ -0,0 +1,76 @@ +import pWaitFor from "p-wait-for" +import delay from "delay" + +import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +import { Terminal } from "../../../integrations/terminal/Terminal" +import type { Task } from "../../task/Task" + +export async function getTerminalContext(cline: Task) { + const state = await cline.providerRef.deref()?.getState() + const { terminalOutputLineLimit = 500 } = state ?? {} + const terminalsData: any[] = [] + const busyTerminals = [ + ...TerminalRegistry.getTerminals(true, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(true), + ] + const inactiveTerminals = [ + ...TerminalRegistry.getTerminals(false, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(false), + ] + + // Wait for terminals to cool down if needed + if (busyTerminals.length > 0 && cline.didEditFile) { + await delay(300) // Delay after saving file to let terminals catch up + await pWaitFor(() => busyTerminals.every((t) => !TerminalRegistry.isProcessHot(t.id)), { + interval: 100, + timeout: 5_000, + }).catch(() => {}) + } + cline.didEditFile = false + + // Process active terminals + busyTerminals.forEach((terminal) => { + const cwd = terminal.getCurrentWorkingDirectory() + const command = terminal.getLastCommand() + let output = TerminalRegistry.getUnretrievedOutput(terminal.id) + + const terminalData: any = { + "@id": terminal.id.toString(), + "@status": "Active", + "@cwd": cwd, + "@command": command, + } + + if (output) { + terminalData["#cdata"] = Terminal.compressTerminalOutput(output, terminalOutputLineLimit) + } + + terminalsData.push(terminalData) + }) + + // Process inactive terminals with output + inactiveTerminals + .filter((t) => t.getProcessesWithOutput().length > 0) + .forEach((terminal) => { + const cwd = terminal.getCurrentWorkingDirectory() + const processes = terminal.getProcessesWithOutput() + + processes.forEach((process) => { + const output = process.getUnretrievedOutput() + + if (output) { + terminalsData.push({ + "@id": terminal.id.toString(), + "@status": "Inactive", + "@cwd": cwd, + "@command": process.command, + "#cdata": Terminal.compressTerminalOutput(output, terminalOutputLineLimit), + }) + } + }) + + terminal.cleanCompletedProcessQueue() + }) + + return terminalsData.length > 0 ? { terminal: terminalsData } : undefined +} diff --git a/src/core/environment/context/todo.ts b/src/core/environment/context/todo.ts new file mode 100644 index 0000000000..b8f39b7c48 --- /dev/null +++ b/src/core/environment/context/todo.ts @@ -0,0 +1,31 @@ +import type { Task } from "../../task/Task" + +export function getTodoContext(cline: Task) { + if (cline.todoList && cline.todoList.length > 0) { + const todoLines = cline.todoList + .map((todo) => { + let statusPrefix = "[ ]" // pending + if (todo.status === "in_progress") statusPrefix = "[-]" + else if (todo.status === "completed") statusPrefix = "[x]" + return `${statusPrefix} ${todo.content}` + }) + .join("\n") + + const todoText = [ + todoLines, + "IMPORTANT: When task status changes, remember to call the `update_todo_list` tool to update your progress.", + ].join("\n") + + return { + todo: { + "#text": todoText, + }, + } + } + return { + todo: { + "#text": + "You have not created a todo list yet. Create one with `update_todo_list` if your task is complicated or involves multiple steps.", + }, + } +} diff --git a/src/core/environment/context/vscode.ts b/src/core/environment/context/vscode.ts new file mode 100644 index 0000000000..870f9fecd8 --- /dev/null +++ b/src/core/environment/context/vscode.ts @@ -0,0 +1,50 @@ +import path from "path" +import * as vscode from "vscode" + +import type { Task } from "../../task/Task" + +export async function getVscodeEditorContext(cline: Task) { + const state = await cline.providerRef.deref()?.getState() + const { maxWorkspaceFiles = 200 } = state ?? {} + + // Get visible files in the editor + const visibleFilePaths = vscode.window.visibleTextEditors + ?.map((editor) => editor.document?.uri?.fsPath) + .filter(Boolean) + .map((absolutePath) => path.relative(cline.cwd, absolutePath)) + .slice(0, maxWorkspaceFiles) + + const allowedVisibleFiles = cline.rooIgnoreController + ? cline.rooIgnoreController.filterPaths(visibleFilePaths) + : visibleFilePaths.map((p) => p.toPosix()) + + const visibleFiles = + allowedVisibleFiles?.length > 0 + ? { + file: allowedVisibleFiles.map((p) => ({ "@path": p })), + } + : undefined + + // Get open tabs (high-frequency data - using compact representation) + const { 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) + + const allowedOpenTabs = cline.rooIgnoreController + ? cline.rooIgnoreController.filterPaths(openTabPaths) + : openTabPaths + + const openTabs = + allowedOpenTabs?.length > 0 + ? { + t: allowedOpenTabs.map((p) => ({ "@p": p })), + } + : undefined + + return { visibleFiles, openTabs } +} diff --git a/src/core/environment/context/workspace.ts b/src/core/environment/context/workspace.ts new file mode 100644 index 0000000000..b322cab0e2 --- /dev/null +++ b/src/core/environment/context/workspace.ts @@ -0,0 +1,56 @@ +import path from "path" +import os from "os" + +import { listFiles } from "../../../services/glob/list-files" +import { arePathsEqual } from "../../../utils/path" +import { formatResponse } from "../../prompts/responses" +import type { Task } from "../../task/Task" + +export async function getWorkspaceContext(cline: Task, includeFileDetails: boolean) { + if (!includeFileDetails) { + return {} + } + const state = await cline.providerRef.deref()?.getState() + const { maxWorkspaceFiles = 200, showRooIgnoredFiles = true } = state ?? {} + + const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop")) + const workspaceData: any = { "@directory": cline.cwd.toPosix() } + + if (isDesktop) { + workspaceData.note = "Desktop files not shown automatically. Use list_files to explore if needed." + } else if (maxWorkspaceFiles === 0) { + workspaceData.note = "Workspace files context disabled. Use list_files to explore if needed." + } else { + const [files, didHitLimit] = await listFiles(cline.cwd, true, maxWorkspaceFiles) + const formattedFilesList = formatResponse.formatFilesList( + cline.cwd, + files, + didHitLimit, + cline.rooIgnoreController, + showRooIgnoredFiles, + ) + + if (formattedFilesList && formattedFilesList !== "No files found.") { + const fileLines = formattedFilesList.split("\n").filter((line) => line.trim() !== "") + const fileObjects: any[] = [] + const dirObjects: any[] = [] + + fileLines.forEach((line) => { + if (line.endsWith("/")) { + dirObjects.push({ "@path": line }) + } else if (!line.includes("File list truncated")) { + fileObjects.push({ "@path": line }) + } + }) + + if (fileObjects.length > 0) workspaceData.file = fileObjects + if (dirObjects.length > 0) workspaceData.directory = dirObjects + if (didHitLimit) { + workspaceData.note = + "File list truncated. Use list_files on specific subdirectories if you need to explore further." + } + } + } + + return { workspace: workspaceData } +} diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 6f0c9fe2bf..a78dadef7f 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -1,259 +1,42 @@ -import path from "path" -import os from "os" - -import * as vscode from "vscode" -import pWaitFor from "p-wait-for" -import delay from "delay" - -import type { ExperimentId } from "@roo-code/types" - -import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments" -import { formatLanguage } from "../../shared/language" -import { defaultModeSlug, getFullModeDetails, getModeBySlug, isToolAllowedForMode } from "../../shared/modes" -import { getApiMetrics } from "../../shared/getApiMetrics" -import { listFiles } from "../../services/glob/list-files" -import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" -import { Terminal } from "../../integrations/terminal/Terminal" -import { arePathsEqual } from "../../utils/path" -import { formatResponse } from "../prompts/responses" +import { XMLBuilder } from "fast-xml-parser" import { Task } from "../task/Task" -import { formatReminderSection } from "./reminder" +import { getVscodeEditorContext } from "./context/vscode" +import { getTerminalContext } from "./context/terminal" +import { getFileContext } from "./context/file" +import { getMetadataContext } from "./context/metadata" +import { getWorkspaceContext } from "./context/workspace" +import { getTodoContext } from "./context/todo" -export async function getEnvironmentDetails(cline: Task, includeFileDetails: boolean = false) { - let details = "" +export async function getEnvironmentDetails(task: Task, includeFileDetails: boolean = false) { + const [vscodeContext, terminalContext, fileContext, metadataContext, workspaceContext, todoContext] = + await Promise.all([ + getVscodeEditorContext(task), + getTerminalContext(task), + getFileContext(task), + getMetadataContext(task), + getWorkspaceContext(task, includeFileDetails), + getTodoContext(task), + ]) - 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)" + const envDetails = { + ...vscodeContext, + ...terminalContext, + ...fileContext, + ...metadataContext, + ...workspaceContext, + ...todoContext, } - details += "\n\n# VSCode Open Tabs" - const { 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. - 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. - } - - // Wait for terminals to cool down. - await pWaitFor(() => busyTerminals.every((t) => !TerminalRegistry.isProcessHot(t.id)), { - interval: 100, - timeout: 5_000, - }).catch(() => {}) - } - - // 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 = "" - - 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 + const builder = new XMLBuilder({ + format: true, // Enable pretty printing + indentBy: " ", // Use two spaces for indentation + ignoreAttributes: false, + attributeNamePrefix: "@", + suppressEmptyNode: true, // Ensures tags with no children are excluded + cdataPropName: "#cdata", + textNodeName: "#text", }) - // 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, - 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 += `${currentMode}\n` - details += `${modeDetails.name}\n` - details += `${modelId}\n` - - if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) { - details += `${modeDetails.roleDefinition}\n` - - if (modeDetails.customInstructions) { - details += `${modeDetails.customInstructions}\n` - } - } - - 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 reminderSection = formatReminderSection(cline.todoList) - return `\n${details.trim()}\n${reminderSection}\n` + return builder.build({ environment_details: envDetails }) } diff --git a/src/core/environment/reminder.ts b/src/core/environment/reminder.ts deleted file mode 100644 index 6edb24364d..0000000000 --- a/src/core/environment/reminder.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { TodoItem, TodoStatus } from "@roo-code/types" - -/** - * Format the reminders section as a markdown block in English, with basic instructions. - */ -export function formatReminderSection(todoList?: TodoItem[]): string { - if (!todoList || todoList.length === 0) { - return "You have not created a todo list yet. Create one with `update_todo_list` if your task is complicated or involves multiple steps." - } - const statusMap: Record = { - pending: "Pending", - in_progress: "In Progress", - completed: "Completed", - } - const lines: string[] = [ - "====", - "", - "REMINDERS", - "", - "Below is your current list of reminders for this task. Keep them updated as you progress.", - "", - ] - - lines.push("| # | Content | Status |") - lines.push("|---|---------|--------|") - todoList.forEach((item, idx) => { - const escapedContent = item.content.replace(/\\/g, "\\\\").replace(/\|/g, "\\|") - lines.push(`| ${idx + 1} | ${escapedContent} | ${statusMap[item.status] || item.status} |`) - }) - lines.push("") - - lines.push( - "", - "IMPORTANT: When task status changes, remember to call the `update_todo_list` tool to update your progress.", - "", - ) - return lines.join("\n") -}