diff --git a/.changeset/thick-dragons-beg.md b/.changeset/thick-dragons-beg.md new file mode 100644 index 00000000000..5e660ddb5fe --- /dev/null +++ b/.changeset/thick-dragons-beg.md @@ -0,0 +1,12 @@ +--- +"roo-cline": minor +--- + +Added Context Memory System to enhance AI-human collaboration through persistent, mode-aware state management. This feature: + +- Implements different memory limits per mode (Code/Architect/Ask) +- Tracks and learns from common coding patterns +- Maintains task progress and technical context +- Records mistakes for improved accuracy +- Integrates with VSCode's global state +- Configurable through the Prompts tab diff --git a/.clinerules b/.clinerules index bc39ae07edb..a5395e674ae 100644 --- a/.clinerules +++ b/.clinerules @@ -1,6 +1,7 @@ # Code Quality Rules 1. Test Coverage: + - Your Dev prefers that functionality be user-test - Before attempting completion, always make sure that any code changes have test coverage - Ensure all tests pass before submitting changes @@ -12,4 +13,86 @@ # Adding a New Setting -To add a new setting that persists its state, follow the steps in cline_docs/settings.md \ No newline at end of file +To add a new setting that persists its state, follow the steps in cline_docs/settings.md + +# Current Project + +# AI Code Collaboration Framework Requirements + +## Purpose +We are supporting Roo-Cline open source. As a supporting DEV I want to create a structured approach for human-AI pair programming that optimizes AI language model interactions by understanding and working with their fundamental characteristics: +- Pattern-based responses +- Stateless nature +- Probability-driven outputs +- Context window limitations + +## Core Problem Statement +AI code assistants operate on pattern recognition and probability distributions but currently lack: +1. Persistent memory between interactions +2. True causal understanding +3. Ability to maintain context over long sessions +4. Consistent constraint adherence without reminders + +## Solution Requirements + +### 1. Context Management +```typescript +// Each interaction needs to be self-contained and explicit +interface DevelopmentContext { + // What are we working on right now? + currentTask: { + scope: string; // Single, focused objective + stage: string; // Where we are in implementation + }; + + // What patterns should the AI follow? + technicalContext: { + framework: string; // e.g., "Vitest", "Jest" + language: string; // e.g., "TypeScript" + patterns: string[]; // Expected code patterns + }; + + // What are the boundaries? + constraints: { + size: string; // e.g., "Single test case" + style: string[]; // e.g., ["Functional", "Immutable"] + dependencies: string[] // Required imports/tools + }; +} +``` + +### 2. Interaction Protocol +Must support: +- One task at a time +- Explicit context setting +- Clear constraints +- Pattern reinforcement +- Regular verification points + +### 3. Development Flow +Each feature development should: +1. Start with smallest testable unit +2. Provide minimal but sufficient context +3. Verify output matches expected patterns +4. Explicitly progress to next step + +## Success Criteria +1. Reduced pattern mixing (e.g., Jest patterns in Vitest code) +2. Consistent adherence to specified constraints +3. Manageable, focused outputs +4. Clear progression through development tasks + +## Implementation Priorities +1. Context Template Structure +2. Interaction Protocol Definition +3. Pattern Library +4. Development Flow Management + +## First Development Task +Create the base context template structure that will: +- Define minimal required context for AI interaction +- Validate context completeness +- Support single-task focus +- Enable pattern enforcement + +## Contributions, we are contributing to a larger project, please match current design patterns and ask the user to provide more information when encountering a broader distribution \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index ead17002914..871fedc59a2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,9 +10,7 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}", - ], + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "sourceMaps": true, "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "compile", @@ -20,10 +18,7 @@ "NODE_ENV": "development", "VSCODE_DEBUG_MODE": "true" }, - "resolveSourceMapLocations": [ - "${workspaceFolder}/**", - "!**/node_modules/**" - ] - }, + "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"] + } ] } diff --git a/jest.config.js b/jest.config.js index 02a4a782896..0e47858176e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -34,7 +34,7 @@ module.exports = { transformIgnorePatterns: [ "node_modules/(?!(@modelcontextprotocol|delay|p-wait-for|globby|serialize-error|strip-ansi|default-shell|os-name)/)", ], - modulePathIgnorePatterns: [".vscode-test"], + modulePathIgnorePatterns: [".vscode-test", "out"], reporters: [["jest-simple-dot-reporter", {}]], setupFiles: [], } diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js index 2e8ed72d048..4f21972de12 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.js @@ -1,4 +1,9 @@ const vscode = { + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, window: { showInformationMessage: jest.fn(), showErrorMessage: jest.fn(), diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 5f8af779857..586531cd4a2 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -53,6 +53,7 @@ import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseN import { formatResponse } from "./prompts/responses" import { SYSTEM_PROMPT } from "./prompts/system" import { modes, defaultModeSlug, getModeBySlug } from "../shared/modes" +import { ContextManager } from "./context/ContextManager" import { truncateHalfConversation } from "./sliding-window" import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider" import { detectCodeOmission } from "../integrations/editor/detect-omission" @@ -80,6 +81,7 @@ export class Cline { diffStrategy?: DiffStrategy diffEnabled: boolean = false fuzzyMatchThreshold: number = 1.0 + private contextManager: ContextManager apiConversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [] clineMessages: ClineMessage[] = [] @@ -131,6 +133,7 @@ export class Cline { this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0 this.providerRef = new WeakRef(provider) this.diffViewProvider = new DiffViewProvider(cwd) + this.contextManager = new ContextManager({ context: provider.context }) if (historyItem) { this.taskId = historyItem.id @@ -435,6 +438,9 @@ export class Cline { this.apiConversationHistory = [] await this.providerRef.deref()?.postStateToWebview() + // Initialize task context + await this.contextManager.initializeTaskContext(this.taskId, task || "") + await this.say("text", task, images) let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) @@ -780,6 +786,14 @@ export class Cline { ] } + // Record command in history and update task progress + await this.contextManager.addCommandToHistory(command) + await this.contextManager.updateTaskProgress( + completed ? "command_completed" : "command_running", + completed ? [command] : [], + completed ? [] : [command], + ) + if (completed) { return [false, `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`] } else { @@ -1337,7 +1351,20 @@ export class Cline { pushToolResult( `The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`, ) + // Record successful file write pattern + await this.contextManager.recordPattern("write_to_file:success") } + + // Update technical context with the modified file + await this.contextManager.updateTechnicalContext({ + lastAnalyzedFiles: [relPath], + projectStructure: { + root: cwd, + mainFiles: [relPath], + dependencies: [], + }, + }) + await this.diffViewProvider.reset() break } @@ -1463,6 +1490,17 @@ export class Cline { `Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`, ) } + + // Update technical context with the modified file + await this.contextManager.updateTechnicalContext({ + lastAnalyzedFiles: [relPath], + projectStructure: { + root: cwd, + mainFiles: [relPath], + dependencies: [], + }, + }) + await this.diffViewProvider.reset() break } @@ -1504,6 +1542,25 @@ export class Cline { } // now execute the tool like normal const content = await extractTextFromFile(absolutePath) + + // Update technical context with analyzed file + await this.contextManager.updateTechnicalContext({ + lastAnalyzedFiles: [relPath], + projectStructure: { + root: cwd, + mainFiles: this.contextManager + .getContext() + .technical.projectStructure.mainFiles.includes(relPath) + ? this.contextManager.getContext().technical.projectStructure.mainFiles + : [ + ...this.contextManager.getContext().technical.projectStructure + .mainFiles, + relPath, + ], + dependencies: [], + }, + }) + pushToolResult(content) break } @@ -1764,6 +1821,12 @@ export class Cline { case "scroll_down": case "scroll_up": await this.say("browser_action_result", JSON.stringify(browserActionResult)) + + // Record successful browser action pattern + await this.contextManager.recordPattern( + `browser_action_result:${action}:executed`, + ) + pushToolResult( formatResponse.toolResult( `The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${ @@ -1910,6 +1973,10 @@ export class Cline { .filter(Boolean) .join("\n\n") || "(No response)" await this.say("mcp_server_response", toolResultPretty) + + // Record successful MCP tool execution pattern + await this.contextManager.recordPattern(`mcp_tool:${server_name}:${tool_name}:executed`) + pushToolResult(formatResponse.toolResult(toolResultPretty)) break } @@ -1971,6 +2038,10 @@ export class Cline { .filter(Boolean) .join("\n\n") || "(Empty response)" await this.say("mcp_server_response", resourceResultPretty) + + // Record successful MCP resource access pattern + await this.contextManager.recordPattern(`mcp_resource:${server_name}:used`) + pushToolResult(formatResponse.toolResult(resourceResultPretty)) break } diff --git a/src/core/config/ContextMemorySchema.ts b/src/core/config/ContextMemorySchema.ts new file mode 100644 index 00000000000..e8a30e19144 --- /dev/null +++ b/src/core/config/ContextMemorySchema.ts @@ -0,0 +1,48 @@ +import { z } from "zod" + +// Schema for mode-specific settings +const ModeSettingsSchema = z.object({ + maxHistoryItems: z.number().min(1, "Must keep at least 1 history item"), + maxPatterns: z.number().min(1, "Must track at least 1 pattern"), + maxMistakes: z.number().min(1, "Must track at least 1 mistake"), +}) + +// Schema for the entire context memory settings file +export const ContextMemorySettingsSchema = z.object({ + enabled: z.boolean(), + modeSettings: z.record(z.string(), ModeSettingsSchema), +}) + +export type ContextMemorySettings = z.infer + +/** + * Default settings to use when none are configured + */ +export const DEFAULT_MEMORY_SETTINGS: ContextMemorySettings = { + enabled: true, + modeSettings: { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + }, +} + +/** + * Validates context memory settings against the schema + * @throws {z.ZodError} if validation fails + */ +export function validateContextMemorySettings(settings: unknown): asserts settings is ContextMemorySettings { + ContextMemorySettingsSchema.parse(settings) +} diff --git a/src/core/context/ContextManager.ts b/src/core/context/ContextManager.ts new file mode 100644 index 00000000000..e466755cc67 --- /dev/null +++ b/src/core/context/ContextManager.ts @@ -0,0 +1,307 @@ +import * as vscode from "vscode" +import { + ContextMemory, + ContextConfig, + TaskContext, + TechnicalContext, + UserContext, + ContextValidationResult, + DEFAULT_CONFIG, + ContextStateKey, + ModeContextSettings, +} from "./types" + +/** + * Manages context memory persistence and operations using VSCode's state management + */ +export class ContextManager { + private context: ContextMemory + private config: Required + + constructor(config: ContextConfig) { + this.config = { + ...DEFAULT_CONFIG, + ...config, + } + this.context = this.createEmptyContext() + } + + /** + * Updates the current mode and its associated settings + */ + public async setMode(mode: string): Promise { + this.config.currentMode = mode + await this.updateSettings({ currentMode: mode }) + } + + /** + * Gets the current mode's settings or falls back to defaults + */ + private getModeSettings(): ModeContextSettings { + const modeSettings = this.config.modeSettings[this.config.currentMode] + if (modeSettings) { + return modeSettings + } + return { + maxHistoryItems: this.config.maxHistoryItems, + maxPatterns: this.config.maxPatterns, + maxMistakes: this.config.maxMistakes, + } + } + + /** + * Checks if context memory is enabled + */ + private isEnabled(): boolean { + return this.config.enabled + } + + /** + * Creates an empty context structure + */ + private createEmptyContext(): ContextMemory { + return { + task: { + id: "", + scope: "", + stage: "", + progress: { + completed: [], + pending: [], + }, + startTime: Date.now(), + lastUpdateTime: Date.now(), + }, + technical: { + patterns: [], + projectStructure: { + root: "", + mainFiles: [], + dependencies: [], + }, + lastAnalyzedFiles: [], + }, + user: { + preferences: {}, + history: { + recentCommands: [], + commonPatterns: [], + mistakes: [], + }, + }, + } + } + + /** + * Updates global state with the specified key and value + */ + private async updateState(key: ContextStateKey, value: unknown): Promise { + await this.config.context.globalState.update(key, value) + } + + /** + * Gets value from global state for the specified key + */ + private async getState(key: ContextStateKey): Promise { + return this.config.context.globalState.get(key) + } + + /** + * Initializes a new task context + */ + public async initializeTaskContext(taskId: string, scope: string): Promise { + this.context.task = { + id: taskId, + scope, + stage: "initializing", + progress: { + completed: [], + pending: [], + }, + startTime: Date.now(), + lastUpdateTime: Date.now(), + } + await this.updateState("taskContext", this.context.task) + } + + /** + * Updates the task stage and progress + */ + public async updateTaskProgress(stage: string, completed?: string[], pending?: string[]): Promise { + this.context.task.stage = stage + if (completed) { + this.context.task.progress.completed = completed + } + if (pending) { + this.context.task.progress.pending = pending + } + this.context.task.lastUpdateTime = Date.now() + await this.updateState("taskContext", this.context.task) + } + + /** + * Updates technical context with new information + */ + public async updateTechnicalContext(updates: Partial): Promise { + this.context.technical = { + ...this.context.technical, + ...updates, + } + await this.updateState("technicalContext", this.context.technical) + } + + /** + * Updates user preferences + */ + public async updateUserPreferences(preferences: UserContext["preferences"]): Promise { + this.context.user.preferences = { + ...this.context.user.preferences, + ...preferences, + } + await this.updateState("userPreferences", this.context.user.preferences) + } + + /** + * Adds a command to recent history + */ + public async addCommandToHistory(command: string): Promise { + if (!this.isEnabled()) return + + this.context.user.history.recentCommands.unshift({ + command, + timestamp: Date.now(), + }) + + // Maintain max history size based on mode settings + const { maxHistoryItems } = this.getModeSettings() + if (this.context.user.history.recentCommands.length > maxHistoryItems) { + this.context.user.history.recentCommands.pop() + } + + await this.updateState("commandHistory", this.context.user.history.recentCommands) + } + + /** + * Records a pattern occurrence + */ + public async recordPattern(pattern: string): Promise { + if (!this.isEnabled()) return + + const existing = this.context.user.history.commonPatterns.find((p) => p.pattern === pattern) + if (existing) { + existing.occurrences++ + } else { + this.context.user.history.commonPatterns.push({ + pattern, + occurrences: 1, + }) + } + + // Sort by occurrences and maintain max size based on mode settings + this.context.user.history.commonPatterns.sort((a, b) => b.occurrences - a.occurrences) + const { maxPatterns } = this.getModeSettings() + if (this.context.user.history.commonPatterns.length > maxPatterns) { + this.context.user.history.commonPatterns.pop() + } + + await this.updateState("patternHistory", this.context.user.history.commonPatterns) + } + + /** + * Records a mistake for learning + */ + public async recordMistake(type: string, description: string): Promise { + if (!this.isEnabled()) return + + this.context.user.history.mistakes.unshift({ + type, + description, + timestamp: Date.now(), + }) + + // Maintain max size based on mode settings + const { maxMistakes } = this.getModeSettings() + if (this.context.user.history.mistakes.length > maxMistakes) { + this.context.user.history.mistakes.pop() + } + + await this.updateState("mistakeHistory", this.context.user.history.mistakes) + } + + /** + * Validates the current context state + */ + public validateContext(): ContextValidationResult { + const missingFields: string[] = [] + const warnings: string[] = [] + + // Validate task context + if (!this.context.task.id) missingFields.push("task.id") + if (!this.context.task.scope) missingFields.push("task.scope") + if (!this.context.task.stage) missingFields.push("task.stage") + + // Add warnings for potentially incomplete data + if (this.context.task.progress.pending.length === 0) { + warnings.push("No pending tasks defined") + } + if (!this.context.technical.projectStructure.root) { + warnings.push("Project root not set") + } + + return { + isValid: missingFields.length === 0, + missingFields, + warnings, + } + } + + /** + * Gets the current context state + */ + public getContext(): ContextMemory { + return { ...this.context } + } + + /** + * Gets a condensed version of the context for inclusion in prompts + */ + public getContextSummary(): string { + return `Current Task: ${this.context.task.scope} (Stage: ${this.context.task.stage}) +Technical Context: ${this.context.technical.language || "Not set"} / ${this.context.technical.framework || "Not set"} +Recent Patterns: ${this.context.user.history.commonPatterns + .slice(0, 3) + .map((p) => p.pattern) + .join(", ")} +Progress: ${this.context.task.progress.completed.length} steps completed, ${this.context.task.progress.pending.length} pending +Context Memory: ${this.isEnabled() ? "Enabled" : "Disabled"} (Mode: ${this.config.currentMode})` + } + + /** + * Updates context memory settings + */ + public async updateSettings(settings: Partial): Promise { + this.config = { + ...this.config, + ...settings, + } + await this.updateState("contextSettings", { + enabled: this.config.enabled, + modeSettings: this.config.modeSettings, + }) + } + + /** + * Gets current context memory settings + */ + public getSettings(): { + enabled: boolean + currentMode: string + modeSettings: Record + } { + return { + enabled: this.config.enabled, + currentMode: this.config.currentMode, + modeSettings: this.config.modeSettings, + } + } +} diff --git a/src/core/context/__tests__/ContextManager.test.ts b/src/core/context/__tests__/ContextManager.test.ts new file mode 100644 index 00000000000..1e704a4ec9e --- /dev/null +++ b/src/core/context/__tests__/ContextManager.test.ts @@ -0,0 +1,242 @@ +import * as vscode from "vscode" +import { ContextManager } from "../ContextManager" +import { contextValidators } from "../validation" +import { ContextMemory, ContextConfig } from "../types" + +// Mock minimal required ExtensionContext properties +const mockGlobalState: vscode.Memento & { setKeysForSync(keys: readonly string[]): void } = { + get: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + keys: jest.fn().mockReturnValue([]), + setKeysForSync: jest.fn(), +} + +const mockContext: Pick = { + globalState: mockGlobalState, + extensionMode: vscode.ExtensionMode.Development, + extensionUri: { + fsPath: "/test/path", + scheme: "file", + } as vscode.Uri, +} + +describe("ContextManager", () => { + let contextManager: ContextManager + + beforeEach(() => { + jest.clearAllMocks() + const config: ContextConfig = { + context: mockContext as vscode.ExtensionContext, + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + } + contextManager = new ContextManager(config) + }) + + describe("Task Context Management", () => { + it("should initialize task context correctly", async () => { + const taskId = "test-task-1" + const scope = "implement feature X" + + await contextManager.initializeTaskContext(taskId, scope) + const context = contextManager.getContext() + + expect(context.task.id).toBe(taskId) + expect(context.task.scope).toBe(scope) + expect(context.task.stage).toBe("initializing") + expect(context.task.progress.completed).toEqual([]) + expect(context.task.progress.pending).toEqual([]) + expect(mockGlobalState.update).toHaveBeenCalledWith("taskContext", expect.any(Object)) + }) + + it("should update task progress correctly", async () => { + const completed = ["step 1", "step 2"] + const pending = ["step 3"] + + await contextManager.initializeTaskContext("test-task", "test scope") + await contextManager.updateTaskProgress("in_progress", completed, pending) + + const context = contextManager.getContext() + expect(context.task.stage).toBe("in_progress") + expect(context.task.progress.completed).toEqual(completed) + expect(context.task.progress.pending).toEqual(pending) + expect(mockGlobalState.update).toHaveBeenCalledWith("taskContext", expect.any(Object)) + }) + }) + + describe("Technical Context Management", () => { + it("should update technical context correctly", async () => { + const technicalContext = { + framework: "react", + language: "typescript", + patterns: ["component-based", "hooks"], + projectStructure: { + root: "/test/project", + mainFiles: ["index.ts"], + dependencies: ["react", "typescript"], + }, + lastAnalyzedFiles: ["src/index.ts"], + } + + await contextManager.updateTechnicalContext(technicalContext) + const context = contextManager.getContext() + + expect(context.technical).toEqual(technicalContext) + expect(mockGlobalState.update).toHaveBeenCalledWith("technicalContext", expect.any(Object)) + }) + }) + + describe("User Context Management", () => { + it("should update user preferences correctly", async () => { + const preferences = { + language: "english", + style: ["functional", "modular"], + } + + await contextManager.updateUserPreferences(preferences) + const context = contextManager.getContext() + + expect(context.user.preferences).toEqual(preferences) + expect(mockGlobalState.update).toHaveBeenCalledWith("userPreferences", expect.any(Object)) + }) + + it("should manage command history correctly", async () => { + const command = "git commit -m 'test'" + await contextManager.addCommandToHistory(command) + + const context = contextManager.getContext() + expect(context.user.history.recentCommands[0].command).toBe(command) + expect(mockGlobalState.update).toHaveBeenCalledWith("commandHistory", expect.any(Array)) + }) + + it("should record and manage patterns correctly", async () => { + const pattern = "component-pattern" + await contextManager.recordPattern(pattern) + await contextManager.recordPattern(pattern) // Record twice + + const context = contextManager.getContext() + const recordedPattern = context.user.history.commonPatterns.find((p) => p.pattern === pattern) + + expect(recordedPattern).toBeDefined() + expect(recordedPattern?.occurrences).toBe(2) + expect(mockGlobalState.update).toHaveBeenCalledWith("patternHistory", expect.any(Array)) + }) + }) + + describe("Context Validation", () => { + it("should validate complete context correctly", async () => { + await contextManager.initializeTaskContext("test-task", "test scope") + await contextManager.updateTaskProgress("in_progress", ["step1"], ["step2"]) + await contextManager.updateTechnicalContext({ + projectStructure: { + root: "/test/project", + mainFiles: ["index.ts"], + dependencies: [], + }, + patterns: [], + lastAnalyzedFiles: [], + }) + + const result = contextValidators.validateContextCompleteness(contextManager.getContext()) + expect(result.isValid).toBe(true) + expect(result.missingFields).toHaveLength(0) + }) + + it("should identify missing required fields", () => { + const result = contextValidators.validateContextCompleteness(contextManager.getContext()) + expect(result.isValid).toBe(false) + expect(result.missingFields.length).toBeGreaterThan(0) + }) + }) + + describe("Context Memory Settings", () => { + it("should respect enabled/disabled state", async () => { + const config: ContextConfig = { + context: mockContext as vscode.ExtensionContext, + enabled: false, + currentMode: "code", + modeSettings: { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + }, + } + contextManager = new ContextManager(config) + + // When disabled, operations should be no-ops + await contextManager.addCommandToHistory("test-command") + await contextManager.recordPattern("test-pattern") + await contextManager.recordMistake("test", "description") + + const context = contextManager.getContext() + expect(context.user.history.recentCommands).toHaveLength(0) + expect(context.user.history.commonPatterns).toHaveLength(0) + expect(context.user.history.mistakes).toHaveLength(0) + }) + + it("should use mode-specific settings", async () => { + const config: ContextConfig = { + context: mockContext as vscode.ExtensionContext, + enabled: true, + currentMode: "architect", + modeSettings: { + architect: { + maxHistoryItems: 5, + maxPatterns: 3, + maxMistakes: 2, + }, + }, + } + contextManager = new ContextManager(config) + + // Add more items than the mode-specific limits + for (let i = 0; i < 10; i++) { + await contextManager.addCommandToHistory(`command-${i}`) + await contextManager.recordPattern(`pattern-${i}`) + await contextManager.recordMistake("test", `mistake-${i}`) + } + + const context = contextManager.getContext() + expect(context.user.history.recentCommands).toHaveLength(5) + expect(context.user.history.commonPatterns).toHaveLength(3) + expect(context.user.history.mistakes).toHaveLength(2) + }) + + it("should update mode settings", async () => { + await contextManager.setMode("ask") + const context = contextManager.getContext() + const summary = contextManager.getContextSummary() + + expect(summary).toContain("Mode: ask") + expect(mockGlobalState.update).toHaveBeenCalledWith("contextSettings", expect.any(Object)) + }) + }) + + describe("Context Summary", () => { + it("should generate meaningful context summary", async () => { + await contextManager.initializeTaskContext("summary-test", "test summary") + await contextManager.updateTaskProgress("in_progress", ["step1"], ["step2", "step3"]) + await contextManager.updateTechnicalContext({ + language: "typescript", + framework: "react", + patterns: [], + projectStructure: { + root: "/test/project", + mainFiles: [], + dependencies: [], + }, + lastAnalyzedFiles: [], + }) + + const summary = contextManager.getContextSummary() + expect(summary).toContain("test summary") + expect(summary).toContain("typescript") + expect(summary).toContain("react") + expect(summary).toContain("1 steps completed") + expect(summary).toContain("2 pending") + }) + }) +}) diff --git a/src/core/context/index.ts b/src/core/context/index.ts new file mode 100644 index 00000000000..b26f1b47970 --- /dev/null +++ b/src/core/context/index.ts @@ -0,0 +1,10 @@ +export { ContextManager } from "./ContextManager" +export { contextValidators, validationUtils } from "./validation" +export type { + ContextMemory, + ContextConfig, + TaskContext, + TechnicalContext, + UserContext, + ContextValidationResult, +} from "./types" diff --git a/src/core/context/types.ts b/src/core/context/types.ts new file mode 100644 index 00000000000..e7e4e8fe73e --- /dev/null +++ b/src/core/context/types.ts @@ -0,0 +1,149 @@ +import * as vscode from "vscode" + +/** + * Core types for the context memory system that integrates with VSCode's state management + */ + +/** + * Represents the complete context memory state + */ +export interface ContextMemory { + // Current task context + task: TaskContext + + // Technical context about the project/codebase + technical: TechnicalContext + + // User-specific context + user: UserContext +} + +/** + * Represents the current task's context + */ +export interface TaskContext { + id: string + scope: string + stage: string + progress: { + completed: string[] + pending: string[] + } + startTime: number + lastUpdateTime: number +} + +/** + * Represents technical context about the project + */ +export interface TechnicalContext { + framework?: string + language?: string + patterns: string[] + projectStructure: { + root: string + mainFiles: string[] + dependencies: string[] + } + lastAnalyzedFiles: string[] +} + +/** + * Represents user-specific context + */ +export interface UserContext { + preferences: Record + history: { + recentCommands: Array<{ + command: string + timestamp: number + }> + commonPatterns: Array<{ + pattern: string + occurrences: number + }> + mistakes: Array<{ + type: string + description: string + timestamp: number + }> + } +} + +/** + * Mode-specific context settings + */ +export interface ModeContextSettings { + maxHistoryItems: number + maxPatterns: number + maxMistakes: number +} + +/** + * Configuration options for context management + * Following VSCode extension patterns for state management + */ +export interface ContextConfig { + // VSCode extension context for state management + context: vscode.ExtensionContext + // Whether context memory is enabled + enabled?: boolean + // Current active mode + currentMode?: string + // Mode-specific settings + modeSettings?: Record + // Fallback settings when mode-specific not available + maxHistoryItems?: number + maxPatterns?: number + maxMistakes?: number +} + +/** + * Default configuration values + */ +export const DEFAULT_CONFIG: Omit, "context"> = { + enabled: true, + currentMode: "code", + modeSettings: { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + }, + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, +} + +/** + * Keys for accessing global state + * Following ClineProvider's pattern + */ +export type ContextStateKey = + | "taskContext" + | "technicalContext" + | "userPreferences" + | "commandHistory" + | "patternHistory" + | "mistakeHistory" + | "contextSettings" + +/** + * Result of a context validation operation + */ +export interface ContextValidationResult { + isValid: boolean + missingFields: string[] + warnings: string[] +} diff --git a/src/core/context/validation.ts b/src/core/context/validation.ts new file mode 100644 index 00000000000..13eff74a340 --- /dev/null +++ b/src/core/context/validation.ts @@ -0,0 +1,162 @@ +import { ContextMemory, ContextValidationResult } from "./types" + +/** + * Validation rules for different context scenarios + */ +interface ValidationRule { + validate: (context: ContextMemory) => { valid: boolean; message?: string } + severity: "error" | "warning" +} + +/** + * Core validation rules for context memory + */ +const contextValidationRules: Record = { + taskInitialization: { + validate: (context) => ({ + valid: Boolean(context.task.id && context.task.scope), + message: "Task must have both ID and scope defined", + }), + severity: "error", + }, + taskProgress: { + validate: (context) => ({ + valid: context.task.progress.completed.length > 0 || context.task.progress.pending.length > 0, + message: "Task should have either completed or pending steps", + }), + severity: "warning", + }, + technicalContext: { + validate: (context) => ({ + valid: Boolean(context.technical.projectStructure.root), + message: "Project root path must be defined", + }), + severity: "error", + }, + recentActivity: { + validate: (context) => ({ + valid: Date.now() - context.task.lastUpdateTime < 30 * 60 * 1000, // 30 minutes + message: "Context may be stale (no updates in last 30 minutes)", + }), + severity: "warning", + }, +} + +/** + * Validates context for specific operations + */ +export const contextValidators = { + /** + * Validates context for tool execution + */ + validateForToolUse(context: ContextMemory): ContextValidationResult { + const missingFields: string[] = [] + const warnings: string[] = [] + + // Check core requirements + if (!context.task.id) missingFields.push("task.id") + if (!context.task.scope) missingFields.push("task.scope") + if (!context.task.stage) missingFields.push("task.stage") + + // Check technical context + if (!context.technical.projectStructure.root) { + missingFields.push("technical.projectStructure.root") + } + + // Add warnings for potentially problematic states + if (context.task.progress.pending.length === 0) { + warnings.push("No pending tasks defined - tool use may lack proper context") + } + if (Date.now() - context.task.lastUpdateTime > 30 * 60 * 1000) { + warnings.push("Context may be stale (no updates in last 30 minutes)") + } + + return { + isValid: missingFields.length === 0, + missingFields, + warnings, + } + }, + + /** + * Validates context for task completion + */ + validateForCompletion(context: ContextMemory): ContextValidationResult { + const missingFields: string[] = [] + const warnings: string[] = [] + + // Ensure all necessary task data is present + if (!context.task.id) missingFields.push("task.id") + if (!context.task.scope) missingFields.push("task.scope") + + // Check task progress + if (context.task.progress.pending.length > 0) { + warnings.push("Task has pending steps that haven't been completed") + } + if (context.task.progress.completed.length === 0) { + warnings.push("No completed steps recorded for this task") + } + + return { + isValid: missingFields.length === 0, + missingFields, + warnings, + } + }, + + /** + * Validates context completeness + */ + validateContextCompleteness(context: ContextMemory): ContextValidationResult { + const missingFields: string[] = [] + const warnings: string[] = [] + + // Apply all validation rules + Object.entries(contextValidationRules).forEach(([key, rule]) => { + const result = rule.validate(context) + if (!result.valid && result.message) { + if (rule.severity === "error") { + missingFields.push(`${key}: ${result.message}`) + } else { + warnings.push(result.message) + } + } + }) + + return { + isValid: missingFields.length === 0, + missingFields, + warnings, + } + }, +} + +/** + * Utility functions for context validation + */ +export const validationUtils = { + /** + * Checks if the technical context is sufficient for the current task + */ + hasSufficientTechnicalContext(context: ContextMemory): boolean { + return Boolean( + context.technical.projectStructure.root && context.technical.projectStructure.mainFiles.length > 0, + ) + }, + + /** + * Checks if the context is fresh enough to be reliable + */ + isContextFresh(context: ContextMemory): boolean { + const maxAge = 30 * 60 * 1000 // 30 minutes + return Date.now() - context.task.lastUpdateTime < maxAge + }, + + /** + * Checks if there are any blocking issues in the context + */ + hasBlockingIssues(context: ContextMemory): boolean { + const { isValid } = contextValidators.validateContextCompleteness(context) + return !isValid + }, +} diff --git a/src/core/sliding-window/__tests__/index.test.ts b/src/core/sliding-window/__tests__/index.test.ts new file mode 100644 index 00000000000..998e57b8d11 --- /dev/null +++ b/src/core/sliding-window/__tests__/index.test.ts @@ -0,0 +1,187 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import { truncateHalfConversation, extractCriticalContext } from ".." +import { ContextManager } from "../../context" +import * as vscode from "vscode" + +// Mock minimal required ExtensionContext properties +const mockGlobalState: vscode.Memento & { setKeysForSync(keys: readonly string[]): void } = { + get: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + keys: jest.fn().mockReturnValue([]), + setKeysForSync: jest.fn(), +} + +const mockContext: Pick = { + globalState: mockGlobalState, + extensionMode: vscode.ExtensionMode.Development, + extensionUri: { + fsPath: "/test/path", + scheme: "file", + } as vscode.Uri, +} + +// Mock messages for testing +const createTestMessages = (): Anthropic.Messages.MessageParam[] => [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "\nImplement feature X\n\n\nProject structure info\n", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text" as const, + text: "Analyzing requirements\nLet's break this down...", + }, + ], + }, + { + role: "user", + content: [ + { + type: "text" as const, + text: "Here's some additional context...", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text" as const, + text: "write_to_file\nWriting implementation...", + }, + ], + }, + { + role: "user", + content: [ + { + type: "text" as const, + text: "Looks good, but consider adding tests", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text" as const, + text: "Adding tests to implementation...", + }, + ], + }, +] + +describe("Sliding Window", () => { + let mockContextManager: ContextManager + + beforeEach(() => { + mockContextManager = new ContextManager({ + context: mockContext as vscode.ExtensionContext, + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }) + }) + + describe("truncateHalfConversation", () => { + it("should always preserve the first task message", () => { + const messages = createTestMessages() + const truncated = truncateHalfConversation(messages, mockContextManager) + + expect(truncated[0]).toBe(messages[0]) + expect(truncated.length).toBeLessThan(messages.length) + }) + + it("should preserve messages with high importance scores", () => { + const messages = createTestMessages() + const truncated = truncateHalfConversation(messages, mockContextManager) + + // Check if messages with task, thinking, or feedback tags are preserved + const preservedContent = truncated + .map((m) => + Array.isArray(m.content) ? m.content.map((c) => ("text" in c ? c.text : "")).join("") : m.content, + ) + .join("") + + expect(preservedContent).toContain("") + expect(preservedContent).toContain("") + expect(preservedContent).toContain("") + }) + + it("should maintain conversation coherence", () => { + const messages = createTestMessages() + const truncated = truncateHalfConversation(messages, mockContextManager) + + // Verify role alternation + for (let i = 1; i < truncated.length; i++) { + expect(truncated[i].role).not.toBe(truncated[i - 1].role) + } + }) + + it("should update context manager with preserved patterns", () => { + const messages = createTestMessages() + const truncated = truncateHalfConversation(messages, mockContextManager) + + const context = mockContextManager.getContext() + expect(context.user.history.commonPatterns.length).toBeGreaterThan(0) + }) + }) + + describe("extractCriticalContext", () => { + it("should extract patterns from messages", () => { + const messages = createTestMessages() + const { patterns, technicalDetails } = extractCriticalContext(messages) + + // Update test to handle multiline content + const taskPattern = patterns.find((p) => p.includes("") && p.includes("Implement feature X")) + const thinkingPattern = patterns.find((p) => p.includes("")) + const feedbackPattern = patterns.find((p) => p.includes("")) + + expect(taskPattern).toBeTruthy() + expect(thinkingPattern).toBeTruthy() + expect(feedbackPattern).toBeTruthy() + }) + + it("should extract technical details", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text" as const, + text: "Let's create a file at `src/components/Feature.tsx`", + }, + ], + }, + ] + + const { technicalDetails } = extractCriticalContext(messages) + expect(technicalDetails).toContain("`src/components/Feature.tsx`") + }) + + it("should deduplicate extracted information", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text" as const, + text: "test\ntest", + }, + ], + }, + ] + + const { patterns } = extractCriticalContext(messages) + const patternCount = patterns.filter((p) => p === "test").length + expect(patternCount).toBe(1) + }) + }) +}) diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index caa604bc576..a7bbca5d8b7 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -1,26 +1,166 @@ import { Anthropic } from "@anthropic-ai/sdk" +import { ContextManager } from "../context" -/* -We can't implement a dynamically updating sliding window as it would break prompt cache -every time. To maintain the benefits of caching, we need to keep conversation history -static. This operation should be performed as infrequently as possible. If a user reaches -a 200k context, we can assume that the first half is likely irrelevant to their current task. -Therefore, this function should only be called when absolutely necessary to fit within -context limits, not as a continuous process. -*/ +interface MessageImportance { + score: number + reason: string +} + +/** + * Analyzes a message to determine its importance for context preservation + */ +function analyzeMessageImportance(message: Anthropic.Messages.MessageParam): MessageImportance { + let score = 0 + const reasons: string[] = [] + + // Helper to check if content contains specific patterns + const containsPattern = (content: string, pattern: string): boolean => + content.toLowerCase().includes(pattern.toLowerCase()) + + // Convert message content to string for analysis + const contentStr = Array.isArray(message.content) + ? message.content + .map((block) => { + if (typeof block === "string") return block + if ("text" in block) return block.text + return "" + }) + .join("\n") + : String(message.content) + + // Check for important context indicators + if (containsPattern(contentStr, "")) { + score += 10 + reasons.push("Contains task definition") + } + + if (containsPattern(contentStr, "environment_details")) { + score += 8 + reasons.push("Contains environment details") + } + + if (containsPattern(contentStr, "")) { + score += 5 + reasons.push("Contains thought process") + } + + // Check for tool usage patterns + if (message.role === "assistant" && containsPattern(contentStr, "")) { + score += 7 + reasons.push("Contains user feedback") + } + + // Check for error context + if (containsPattern(contentStr, "") || containsPattern(contentStr, "error:")) { + score += 4 + reasons.push("Contains error context") + } + + return { + score, + reason: reasons.join(", "), + } +} + +/** + * Enhanced truncation that preserves critical context + */ export function truncateHalfConversation( messages: Anthropic.Messages.MessageParam[], + contextManager?: ContextManager, ): Anthropic.Messages.MessageParam[] { - // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating. - - // Always keep the first Task message (this includes the project's file structure in environment_details) + // Always keep the first Task message const truncatedMessages = [messages[0]] - // Remove half of user-assistant pairs - const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number + // Analyze importance of remaining messages + const messageScores: Array<{ message: Anthropic.Messages.MessageParam; importance: MessageImportance }> = messages + .slice(1) + .map((message) => ({ + message, + importance: analyzeMessageImportance(message), + })) + + // Sort messages by importance score + messageScores.sort((a, b) => b.importance.score - a.importance.score) + + // Calculate how many messages we need to remove + const targetLength = Math.ceil(messages.length / 2) + const messagesToKeep = messageScores + .slice(0, targetLength - 1) // -1 because we already kept the first message + .map((item) => item.message) + + // Restore original order for messages we're keeping + const messageIndices = new Map(messages.map((msg, idx) => [msg, idx])) + messagesToKeep.sort((a, b) => { + const indexA = messageIndices.get(a) ?? 0 + const indexB = messageIndices.get(b) ?? 0 + return indexA - indexB + }) + + // Update context manager if provided + if (contextManager) { + // Extract and preserve critical information before truncation + const preservedPatterns = new Set() + messageScores.forEach(({ message, importance }) => { + if (importance.score >= 5) { + // Threshold for pattern preservation + const content = Array.isArray(message.content) + ? message.content + .map((block) => (typeof block === "string" ? block : "text" in block ? block.text : "")) + .join("\n") + : String(message.content) + + // Extract and record patterns from important messages + const patterns = content.match(/<([^>]+)>[^<]*<\/\1>/g) || [] + patterns.forEach((pattern) => preservedPatterns.add(pattern)) + } + }) + + // Record preserved patterns in context + preservedPatterns.forEach((pattern) => { + contextManager.recordPattern(pattern) + }) + } - const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use - truncatedMessages.push(...remainingMessages) + truncatedMessages.push(...messagesToKeep) return truncatedMessages } + +/** + * Extracts critical context from a conversation for preservation + */ +export function extractCriticalContext(messages: Anthropic.Messages.MessageParam[]): { + patterns: string[] + technicalDetails: string[] +} { + const patterns: string[] = [] + const technicalDetails: string[] = [] + + messages.forEach((message) => { + const content = Array.isArray(message.content) + ? message.content + .map((block) => (typeof block === "string" ? block : "text" in block ? block.text : "")) + .join("\n") + : String(message.content) + + // Extract patterns (XML-like tags with content) + const patternMatches = content.match(/<([^>]+)>[^<]*<\/\1>/g) || [] + patterns.push(...patternMatches) + + // Extract technical details (file paths, commands, etc.) + const technicalMatches = content.match(/(?:\/[\w.-]+)+|\`[^`]+\`/g) || [] + technicalDetails.push(...technicalMatches) + }) + + return { + patterns: [...new Set(patterns)], + technicalDetails: [...new Set(technicalDetails)], + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fada0313926..7d1022de367 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -40,6 +40,7 @@ import { enhancePrompt } from "../../utils/enhance-prompt" import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" import { ConfigManager } from "../config/ConfigManager" import { CustomModesManager } from "../config/CustomModesManager" +import { ModeContextSettings, DEFAULT_CONFIG } from "../context/types" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -112,6 +113,8 @@ type GlobalStateKey = | "experimentalDiffStrategy" | "autoApprovalEnabled" | "customModes" // Array of custom modes + | "contextMemoryEnabled" + | "contextMemoryModeSettings" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -201,7 +204,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { webviewView.webview.html = this.getHtmlContent(webviewView.webview) // Sets up an event listener to listen for messages passed from the webview view context - // and executes code based on the message that is recieved + // and executes code based on the message that is received this.setWebviewMessageListener(webviewView.webview) // Logs show up in bottom panel > Debug Console @@ -744,6 +747,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { const newMode = message.text as Mode await this.updateGlobalState("mode", newMode) + // Mode changes don't need to be part of the conversation history + // Just update the mode and continue with the existing conversation + // Load the saved API config for the new mode if it exists const savedConfigId = await this.configManager.getModeConfigId(newMode) const listApiConfig = await this.configManager.listConfig() @@ -1172,6 +1178,41 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("mode", defaultModeSlug) await this.postStateToWebview() } + break + case "contextMemoryEnabled": + await this.updateGlobalState("contextMemoryEnabled", message.bool ?? DEFAULT_CONFIG.enabled) + await this.postStateToWebview() + break + case "contextMemorySettings": + if (message.values && typeof message.values === "object") { + const newSettings = message.values as Record + const existingSettings = (await this.getGlobalState("contextMemoryModeSettings")) ?? {} + + // Validate settings and use defaults only for invalid values + const validatedSettings: Record = {} + for (const [mode, settings] of Object.entries(newSettings)) { + if (mode in DEFAULT_CONFIG.modeSettings) { + const modeKey = mode as Mode + if ( + !settings.maxHistoryItems || + settings.maxHistoryItems < 1 || + !settings.maxPatterns || + settings.maxPatterns < 1 || + !settings.maxMistakes || + settings.maxMistakes < 1 + ) { + validatedSettings[modeKey] = DEFAULT_CONFIG.modeSettings[modeKey] + } else { + validatedSettings[modeKey] = settings + } + } + } + + // Merge with existing settings, preserving modes not included in the update + const updatedSettings = { ...existingSettings, ...validatedSettings } + await this.updateGlobalState("contextMemoryModeSettings", updatedSettings) + await this.postStateToWebview() + } } }, null, @@ -1714,7 +1755,31 @@ export class ClineProvider implements vscode.WebviewViewProvider { async postStateToWebview() { const state = await this.getStateToPostToWebview() - this.postMessageToWebview({ type: "state", state }) + const { contextMemoryEnabled, contextMemoryModeSettings } = await this.getState() + this.postMessageToWebview({ + type: "state", + state: { + ...state, + contextMemoryEnabled: contextMemoryEnabled ?? false, + contextMemoryModeSettings: contextMemoryModeSettings ?? { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + }, + }, + }) } async getStateToPostToWebview() { @@ -1747,6 +1812,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { enhancementApiConfigId, experimentalDiffStrategy, autoApprovalEnabled, + contextMemoryEnabled, + contextMemoryModeSettings, } = await this.getState() const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get("allowedCommands") || [] @@ -1787,6 +1854,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { experimentalDiffStrategy: experimentalDiffStrategy ?? false, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes: await this.customModesManager.getCustomModes(), + contextMemoryEnabled: contextMemoryEnabled ?? DEFAULT_CONFIG.enabled, + contextMemoryModeSettings: contextMemoryModeSettings ?? DEFAULT_CONFIG.modeSettings, } } @@ -1906,6 +1975,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { experimentalDiffStrategy, autoApprovalEnabled, customModes, + contextMemoryEnabled, + contextMemoryModeSettings, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1970,6 +2041,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("experimentalDiffStrategy") as Promise, this.getGlobalState("autoApprovalEnabled") as Promise, this.customModesManager.getCustomModes(), + this.getGlobalState("contextMemoryEnabled") as Promise, + this.getGlobalState("contextMemoryModeSettings") as Promise | undefined>, ]) let apiProvider: ApiProvider @@ -2080,6 +2153,24 @@ export class ClineProvider implements vscode.WebviewViewProvider { experimentalDiffStrategy: experimentalDiffStrategy ?? false, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes, + contextMemoryEnabled: contextMemoryEnabled ?? false, + contextMemoryModeSettings: contextMemoryModeSettings ?? { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + }, } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 63dc2d58e16..e9b2f953198 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -320,6 +320,24 @@ describe("ClineProvider", () => { requestDelaySeconds: 5, mode: defaultModeSlug, customModes: [], + contextMemoryEnabled: true, + contextMemoryModeSettings: { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + }, } const message: ExtensionMessage = { @@ -460,53 +478,118 @@ describe("ClineProvider", () => { expect(state.alwaysApproveResubmit).toBe(false) }) - test("loads saved API config when switching modes", async () => { - provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + describe("mode switching", () => { + test("updates mode without modifying conversation history", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Mock ConfigManager methods - provider.configManager = { - getModeConfigId: jest.fn().mockResolvedValue("test-id"), - listConfig: jest.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), - loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }), - setModeConfig: jest.fn(), - } as any + // Setup mock Cline instance with conversation history + const mockHistory = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi" }, + ] + const mockCline = { + apiConversationHistory: mockHistory, + overwriteApiConversationHistory: jest.fn(), + } - // Switch to architect mode - await messageHandler({ type: "mode", text: "architect" }) + // Mock ConfigManager methods + provider.configManager = { + getModeConfigId: jest.fn().mockResolvedValue("test-id"), + listConfig: jest + .fn() + .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }), + setModeConfig: jest.fn(), + } as any - // Should load the saved config for architect mode - expect(provider.configManager.getModeConfigId).toHaveBeenCalledWith("architect") - expect(provider.configManager.loadConfig).toHaveBeenCalledWith("test-config") - expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") - }) + // Switch to architect mode + await messageHandler({ type: "mode", text: "architect" }) - test("saves current config when switching to mode without config", async () => { - provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + // Should load the saved config for architect mode + expect(provider.configManager.getModeConfigId).toHaveBeenCalledWith("architect") + expect(provider.configManager.loadConfig).toHaveBeenCalledWith("test-config") + expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") + }) - // Mock ConfigManager methods - provider.configManager = { - getModeConfigId: jest.fn().mockResolvedValue(undefined), - listConfig: jest - .fn() - .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), - setModeConfig: jest.fn(), - } as any + test("saves current config when switching to mode without config", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Mock current config name - ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { - if (key === "currentApiConfigName") { - return "current-config" - } - return undefined + // Mock ConfigManager methods + provider.configManager = { + getModeConfigId: jest.fn().mockResolvedValue(undefined), + listConfig: jest + .fn() + .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), + setModeConfig: jest.fn(), + } as any + + // Mock current config name + ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === "currentApiConfigName") { + return "current-config" + } + return undefined + }) + + // Switch to architect mode + await messageHandler({ type: "mode", text: "architect" }) + + // Should save current config as default for architect mode + expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") }) - // Switch to architect mode - await messageHandler({ type: "mode", text: "architect" }) + test("loads saved API config when switching modes", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Mock ConfigManager methods + provider.configManager = { + getModeConfigId: jest.fn().mockResolvedValue("test-id"), + listConfig: jest + .fn() + .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic" }), + setModeConfig: jest.fn(), + } as any + + // Switch to architect mode + await messageHandler({ type: "mode", text: "architect" }) + + // Should load the saved config for architect mode + expect(provider.configManager.getModeConfigId).toHaveBeenCalledWith("architect") + expect(provider.configManager.loadConfig).toHaveBeenCalledWith("test-config") + expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") + }) + + test("saves current config when switching to mode without config", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - // Should save current config as default for architect mode - expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") + // Mock ConfigManager methods + provider.configManager = { + getModeConfigId: jest.fn().mockResolvedValue(undefined), + listConfig: jest + .fn() + .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), + setModeConfig: jest.fn(), + } as any + + // Mock current config name + ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === "currentApiConfigName") { + return "current-config" + } + return undefined + }) + + // Switch to architect mode + await messageHandler({ type: "mode", text: "architect" }) + + // Should save current config as default for architect mode + expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") + }) }) test("saves config as default for current mode when loading config", async () => { @@ -1165,5 +1248,175 @@ describe("ClineProvider", () => { }), ) }) + + describe("context memory settings", () => { + test("handles contextMemoryEnabled message", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + await messageHandler({ type: "contextMemoryEnabled", bool: true }) + expect(mockContext.globalState.update).toHaveBeenCalledWith("contextMemoryEnabled", true) + expect(mockPostMessage).toHaveBeenCalled() + + await messageHandler({ type: "contextMemoryEnabled", bool: false }) + expect(mockContext.globalState.update).toHaveBeenCalledWith("contextMemoryEnabled", false) + expect(mockPostMessage).toHaveBeenCalled() + }) + + test("validates mode settings values", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Test with invalid values (below minimum) + const invalidSettings = { + code: { + maxHistoryItems: 0, // Invalid: below minimum of 1 + maxPatterns: 0, // Invalid: below minimum of 1 + maxMistakes: 0, // Invalid: below minimum of 1 + }, + } + + await messageHandler({ type: "contextMemorySettings", values: invalidSettings }) + + // Should fall back to default values + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "contextMemoryModeSettings", + expect.objectContaining({ + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + }), + ) + + // Test with valid values + const validSettings = { + code: { + maxHistoryItems: 100, + maxPatterns: 30, + maxMistakes: 15, + }, + } + + await messageHandler({ type: "contextMemorySettings", values: validSettings }) + expect(mockContext.globalState.update).toHaveBeenCalledWith("contextMemoryModeSettings", validSettings) + }) + + test("handles partial mode settings updates", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Mock existing settings + const existingSettings = { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + } + + ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === "contextMemoryModeSettings") { + return existingSettings + } + return null + }) + + // Update only one mode's settings + const partialUpdate = { + code: { + maxHistoryItems: 75, + maxPatterns: 25, + maxMistakes: 12, + }, + } + + await messageHandler({ type: "contextMemorySettings", values: partialUpdate }) + + // Should preserve other mode settings + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "contextMemoryModeSettings", + expect.objectContaining({ + code: partialUpdate.code, + architect: existingSettings.architect, + }), + ) + }) + + test("handles contextMemorySettings message", async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + const settings = { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + } + + await messageHandler({ type: "contextMemorySettings", values: settings }) + expect(mockContext.globalState.update).toHaveBeenCalledWith("contextMemoryModeSettings", settings) + expect(mockPostMessage).toHaveBeenCalled() + }) + + test("contextMemoryEnabled defaults to false", async () => { + // Mock globalState.get to return undefined for contextMemoryEnabled + ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === "contextMemoryEnabled") { + return undefined + } + return null + }) + + const state = await provider.getState() + expect(state.contextMemoryEnabled).toBe(false) + }) + + test("contextMemoryModeSettings uses default values when not set", async () => { + // Mock globalState.get to return undefined for contextMemoryModeSettings + ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === "contextMemoryModeSettings") { + return undefined + } + return null + }) + + const state = await provider.getState() + expect(state.contextMemoryModeSettings).toEqual({ + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + }) + }) + }) }) }) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 3bcd8a0a3ad..b0414a17228 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -108,6 +108,15 @@ export interface ExtensionState { experimentalDiffStrategy?: boolean autoApprovalEnabled?: boolean customModes: ModeConfig[] + contextMemoryEnabled: boolean + contextMemoryModeSettings: Record< + Mode, + { + maxHistoryItems: number + maxPatterns: number + maxMistakes: number + } + > toolRequirements?: Record // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled) } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 57053785563..ce29115d064 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -77,6 +77,8 @@ export interface WebviewMessage { | "updateCustomMode" | "deleteCustomMode" | "setopenAiCustomModelInfo" + | "contextMemoryEnabled" + | "contextMemorySettings" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/src/shared/context-defaults.ts b/src/shared/context-defaults.ts new file mode 100644 index 00000000000..2d227cf7b81 --- /dev/null +++ b/src/shared/context-defaults.ts @@ -0,0 +1,28 @@ +import { Mode } from "./modes" + +export interface ContextSettings { + maxHistoryItems: number + maxPatterns: number + maxMistakes: number +} + +/** + * Default context memory settings per mode + */ +export const DEFAULT_CONTEXT_SETTINGS: Record = { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, +} diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index acddd070858..b4ec9545120 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -40,6 +40,7 @@ "@types/vscode-webview": "^1.57.5", "customize-cra": "^1.0.0", "eslint": "^8.57.0", + "eslint-config-react-app": "^7.0.1", "jest-simple-dot-reporter": "^1.0.5", "react-app-rewired": "^2.2.1" } @@ -6405,6 +6406,8 @@ }, "node_modules/eslint-config-react-app": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", + "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", "license": "MIT", "dependencies": { "@babel/core": "^7.16.0", diff --git a/webview-ui/package.json b/webview-ui/package.json index a7c616d1166..e28acf2b029 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -60,6 +60,7 @@ "@types/vscode-webview": "^1.57.5", "customize-cra": "^1.0.0", "eslint": "^8.57.0", + "eslint-config-react-app": "^7.0.1", "jest-simple-dot-reporter": "^1.0.5", "react-app-rewired": "^2.2.1" } diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index d4aef4509b7..5d136444a10 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -16,6 +16,7 @@ import { ModeConfig, enhancePrompt, } from "../../../../src/shared/modes" +import { DEFAULT_CONTEXT_SETTINGS } from "../../../../src/shared/context-defaults" import { TOOL_GROUPS, GROUP_DISPLAY_NAMES, ToolGroup } from "../../../../src/shared/tool-groups" import { vscode } from "../../utils/vscode" @@ -38,6 +39,10 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { preferredLanguage, setPreferredLanguage, customModes, + contextMemoryEnabled, + setContextMemoryEnabled, + contextMemoryModeSettings, + setContextMemoryModeSettings, } = useExtensionState() // Memoize modes to preserve array order @@ -667,6 +672,139 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { + {/* Context Memory Settings */} +
+
+
Context Memory Settings
+ { + const target = (e as CustomEvent)?.detail?.target || (e.target as HTMLInputElement) + setContextMemoryEnabled(target.checked) + }}> + Enabled + +
+
+ Configure how much context to maintain for {getCurrentMode()?.name || "Code"} mode. +
+
+
+
+ History items + { + const newSettings = { + ...contextMemoryModeSettings, + [mode]: { + ...contextMemoryModeSettings[mode], + maxHistoryItems: parseInt(e.target.value), + }, + } + setContextMemoryModeSettings(newSettings) + }} + style={{ + flexGrow: 1, + accentColor: "var(--vscode-button-background)", + height: "2px", + }} + /> + + {contextMemoryModeSettings[mode]?.maxHistoryItems ?? + DEFAULT_CONTEXT_SETTINGS[mode]?.maxHistoryItems} + +
+
+
+
+ Patterns + { + const newSettings = { + ...contextMemoryModeSettings, + [mode]: { + ...contextMemoryModeSettings[mode], + maxPatterns: parseInt(e.target.value), + }, + } + setContextMemoryModeSettings(newSettings) + }} + style={{ + flexGrow: 1, + accentColor: "var(--vscode-button-background)", + height: "2px", + }} + /> + + {contextMemoryModeSettings[mode]?.maxPatterns ?? + DEFAULT_CONTEXT_SETTINGS[mode]?.maxPatterns} + +
+
+
+
+ Mistakes + { + const newSettings = { + ...contextMemoryModeSettings, + [mode]: { + ...contextMemoryModeSettings[mode], + maxMistakes: parseInt(e.target.value), + }, + } + setContextMemoryModeSettings(newSettings) + }} + style={{ + flexGrow: 1, + accentColor: "var(--vscode-button-background)", + height: "2px", + }} + /> + + {contextMemoryModeSettings[mode]?.maxMistakes ?? + DEFAULT_CONTEXT_SETTINGS[mode]?.maxMistakes} + +
+
+
+
+ {/* Role definition for both built-in and custom modes */}
Mode-specific Custom Instructions
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ea00a0c82ad..2a0a06eb7ce 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -25,6 +25,27 @@ export interface ExtensionStateContextType extends ExtensionState { openAiModels: string[] mcpServers: McpServer[] filePaths: string[] + // Context memory settings are managed in PromptsView since they are mode-specific + contextMemoryEnabled: boolean + contextMemoryModeSettings: Record< + Mode, + { + maxHistoryItems: number + maxPatterns: number + maxMistakes: number + } + > + setContextMemoryEnabled: (value: boolean) => void + setContextMemoryModeSettings: ( + value: Record< + Mode, + { + maxHistoryItems: number + maxPatterns: number + maxMistakes: number + } + >, + ) => void setApiConfiguration: (config: ApiConfiguration) => void setCustomInstructions: (value?: string) => void setAlwaysAllowReadOnly: (value: boolean) => void @@ -98,6 +119,24 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode experimentalDiffStrategy: false, autoApprovalEnabled: false, customModes: [], + contextMemoryEnabled: true, + contextMemoryModeSettings: { + code: { + maxHistoryItems: 50, + maxPatterns: 20, + maxMistakes: 10, + }, + architect: { + maxHistoryItems: 30, + maxPatterns: 15, + maxMistakes: 5, + }, + ask: { + maxHistoryItems: 20, + maxPatterns: 10, + maxMistakes: 3, + }, + }, }) const [didHydrateState, setDidHydrateState] = useState(false) @@ -266,10 +305,21 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), + setContextMemoryEnabled: (value) => { + setState((prevState) => ({ ...prevState, contextMemoryEnabled: value })) + vscode.postMessage({ type: "contextMemoryEnabled", bool: value }) + }, + setContextMemoryModeSettings: (value) => { + setState((prevState) => ({ ...prevState, contextMemoryModeSettings: value })) + vscode.postMessage({ type: "contextMemorySettings", values: value }) + }, setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), setListApiConfigMeta, onUpdateApiConfig, - setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })), + setMode: (value: Mode) => { + setState((prevState) => ({ ...prevState, mode: value })) + vscode.postMessage({ type: "mode", mode: value }) + }, setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })), setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),