diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 6de4d7413f..7458480e90 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -146,6 +146,9 @@ export const globalSettingsSchema = z.object({ hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), lastModeImportPath: z.string().optional(), + + enableHierarchicalMemory: z.boolean().optional(), + hierarchicalMemoryFileNames: z.array(z.string()).optional(), }) export type GlobalSettings = z.infer diff --git a/src/core/memory/HierarchicalMemoryManager.ts b/src/core/memory/HierarchicalMemoryManager.ts new file mode 100644 index 0000000000..582ee1b24c --- /dev/null +++ b/src/core/memory/HierarchicalMemoryManager.ts @@ -0,0 +1,61 @@ +import path from "path" +import { ApiMessage } from "../task-persistence/apiMessages" +import { fileExistsAtPath } from "../../utils/fs" +import fs from "fs/promises" + +export class HierarchicalMemoryManager { + private readonly read = new Set() + + constructor( + private readonly enabled: boolean, + private readonly names: string[], + ) {} + + async loadFor(filePath: string, root: string): Promise { + if (!this.enabled || this.names.length === 0) return [] + + const messages: ApiMessage[] = [] + let dir = path.dirname(path.resolve(filePath)) + root = path.resolve(root) + + while (dir.startsWith(root)) { + for (const name of this.names) { + const full = path.join(dir, name) + if (!this.read.has(full)) { + try { + const exists = await fileExistsAtPath(full) + if (exists) { + const body = await fs.readFile(full, "utf8") + messages.push({ + role: "user", + content: `--- Memory from ${full} ---\n${body}`, + ts: Date.now(), + isHierarchicalMemory: true, + }) + this.read.add(full) + } + } catch (e: any) { + if (e.code !== "ENOENT") console.error(e) + } + } + } + if (dir === root) break + dir = path.dirname(dir) + } + return messages.reverse() // root → leaf + } + + /** + * Get all loaded memory files + */ + getLoadedMemories(): string[] { + return Array.from(this.read) + } + + /** + * Clear the cache of loaded memories + */ + clearCache(): void { + this.read.clear() + } +} diff --git a/src/core/memory/__tests__/HierarchicalMemoryManager.test.ts b/src/core/memory/__tests__/HierarchicalMemoryManager.test.ts new file mode 100644 index 0000000000..6af465d4b5 --- /dev/null +++ b/src/core/memory/__tests__/HierarchicalMemoryManager.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import { HierarchicalMemoryManager } from "../HierarchicalMemoryManager" +import { fileExistsAtPath } from "../../../utils/fs" + +// Mock fs/promises +vi.mock("fs/promises") +// Mock fileExistsAtPath +vi.mock("../../../utils/fs") + +describe("HierarchicalMemoryManager", () => { + let manager: HierarchicalMemoryManager + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with enabled state and file names", () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md", "Roorules.md"]) + expect(manager).toBeDefined() + }) + + it("should initialize with disabled state", () => { + manager = new HierarchicalMemoryManager(false, []) + expect(manager).toBeDefined() + }) + }) + + describe("loadFor", () => { + it("should return empty array when disabled", async () => { + manager = new HierarchicalMemoryManager(false, ["CLAUDE.md"]) + const result = await manager.loadFor("/project/src/file.ts", "/project") + expect(result).toEqual([]) + }) + + it("should return empty array when no file names configured", async () => { + manager = new HierarchicalMemoryManager(true, []) + const result = await manager.loadFor("/project/src/file.ts", "/project") + expect(result).toEqual([]) + }) + + it("should load memory files from parent directories", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // Mock file system + vi.mocked(fileExistsAtPath).mockImplementation(async (filePath) => { + return ( + filePath === path.join("/project/src", "CLAUDE.md") || + filePath === path.join("/project", "CLAUDE.md") + ) + }) + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + if (filePath === path.join("/project/src", "CLAUDE.md")) { + return "# Source Memory\nThis is source directory memory." + } + if (filePath === path.join("/project", "CLAUDE.md")) { + return "# Project Memory\nThis is project root memory." + } + throw new Error("File not found") + }) + + const result = await manager.loadFor("/project/src/components/file.ts", "/project") + + expect(result).toHaveLength(2) + // Results are reversed (root → leaf), so project memory comes first + expect(result[0]).toMatchObject({ + role: "user", + content: expect.stringContaining("Memory from /project/CLAUDE.md"), + isHierarchicalMemory: true, + }) + expect(result[1]).toMatchObject({ + role: "user", + content: expect.stringContaining("Memory from /project/src/CLAUDE.md"), + isHierarchicalMemory: true, + }) + }) + + it("should not load duplicate memory files", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // Mock file system + vi.mocked(fileExistsAtPath).mockImplementation(async (filePath) => { + return filePath === path.join("/project", "CLAUDE.md") + }) + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + if (filePath === path.join("/project", "CLAUDE.md")) { + return "# Project Memory\nThis is project root memory." + } + throw new Error("File not found") + }) + + // Load for the first file + const result1 = await manager.loadFor("/project/src/file1.ts", "/project") + expect(result1).toHaveLength(1) + + // Load for the second file in the same directory - should not reload the same memory + const result2 = await manager.loadFor("/project/src/file2.ts", "/project") + expect(result2).toHaveLength(0) + }) + + it("should handle file read errors gracefully", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // Mock file system + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied")) + + const result = await manager.loadFor("/project/src/file.ts", "/project") + expect(result).toEqual([]) + }) + + it("should stop at root directory", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // Mock file system + vi.mocked(fileExistsAtPath).mockImplementation(async (filePath) => { + return filePath === path.join("/project", "CLAUDE.md") + }) + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + if (filePath === path.join("/project", "CLAUDE.md")) { + return "# Project Memory" + } + throw new Error("File not found") + }) + + const result = await manager.loadFor("/project/src/file.ts", "/project") + expect(result).toHaveLength(1) + + // Should not try to read beyond root + expect(fs.readFile).not.toHaveBeenCalledWith(path.join("/", "CLAUDE.md"), "utf-8") + }) + + it("should handle multiple memory file names", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md", "Roorules.md", ".context.md"]) + + // Mock file system + vi.mocked(fileExistsAtPath).mockImplementation(async (filePath) => { + const fileName = path.basename(filePath.toString()) + const dirName = path.dirname(filePath.toString()) + + return ( + (fileName === "CLAUDE.md" && dirName === "/project") || + (fileName === "Roorules.md" && dirName === "/project") || + (fileName === ".context.md" && dirName === "/project/src") + ) + }) + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const fileName = path.basename(filePath.toString()) + const dirName = path.dirname(filePath.toString()) + + if (fileName === "CLAUDE.md" && dirName === "/project") { + return "# CLAUDE Memory" + } + if (fileName === "Roorules.md" && dirName === "/project") { + return "# Roo Rules" + } + if (fileName === ".context.md" && dirName === "/project/src") { + return "# Context Memory" + } + throw new Error("File not found") + }) + + const result = await manager.loadFor("/project/src/file.ts", "/project") + expect(result).toHaveLength(3) + + // Check that all three files were loaded + const contents = result.map((msg) => msg.content.toString()) + expect(contents.some((c) => c.includes("Context Memory"))).toBe(true) + expect(contents.some((c) => c.includes("CLAUDE Memory"))).toBe(true) + expect(contents.some((c) => c.includes("Roo Rules"))).toBe(true) + }) + + it("should handle empty memory files", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // Mock file system - only one file exists + vi.mocked(fileExistsAtPath).mockImplementation(async (filePath) => { + return filePath === path.join("/project", "CLAUDE.md") + }) + vi.mocked(fs.readFile).mockResolvedValue("") + + const result = await manager.loadFor("/project/src/file.ts", "/project") + expect(result).toHaveLength(1) // Empty files are still loaded + }) + + it("should include content with whitespace", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // Mock file system - only one file exists + vi.mocked(fileExistsAtPath).mockImplementation(async (filePath) => { + return filePath === path.join("/project", "CLAUDE.md") + }) + vi.mocked(fs.readFile).mockResolvedValue("\n\n # Memory Content \n\n") + + const result = await manager.loadFor("/project/src/file.ts", "/project") + expect(result).toHaveLength(1) + expect(result[0].content).toContain("# Memory Content") + }) + }) + + describe("edge cases", () => { + it("should handle file path at root directory", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue("# Root Memory") + + const result = await manager.loadFor("/file.ts", "/") + expect(result).toHaveLength(1) + }) + + it.skip("should handle Windows-style paths", async () => { + // Skip this test on Unix systems as path handling is OS-specific + // The implementation uses path.resolve which behaves differently on Windows vs Unix + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // Mock file system - handle Windows paths + vi.mocked(fileExistsAtPath).mockImplementation(async (filePath) => { + const fp = filePath.toString() + return fp.endsWith("CLAUDE.md") + }) + + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const fp = filePath.toString() + if (fp.endsWith("CLAUDE.md")) { + return "# Windows Memory" + } + throw new Error("File not found") + }) + + const result = await manager.loadFor("C:\\project\\src\\file.ts", "C:\\project") + expect(result.length).toBeGreaterThanOrEqual(1) + }) + + it("should handle relative file paths by converting to absolute", async () => { + manager = new HierarchicalMemoryManager(true, ["CLAUDE.md"]) + + // For relative paths, the manager should still work correctly + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue("# Memory") + + const result = await manager.loadFor("./src/file.ts", ".") + // Should still attempt to check for memory files + expect(fileExistsAtPath).toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index f846aaf13f..fca03270aa 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -9,7 +9,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { GlobalFileNames } from "../../shared/globalFileNames" import { getTaskDirectoryPath } from "../../utils/storage" -export type ApiMessage = Anthropic.MessageParam & { ts?: number; isSummary?: boolean } +export type ApiMessage = Anthropic.MessageParam & { ts?: number; isSummary?: boolean; isHierarchicalMemory?: boolean } export async function readApiMessages({ taskId, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9df9a225d1..f9fbbadb52 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -93,6 +93,7 @@ import { getMessagesSinceLastSummary, summarizeConversation } from "../condense" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { restoreTodoListForTask } from "../tools/updateTodoListTool" import { AutoApprovalHandler } from "./AutoApprovalHandler" +import { HierarchicalMemoryManager } from "../memory/HierarchicalMemoryManager" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes @@ -141,6 +142,7 @@ export class Task extends EventEmitter { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string + memoryManager?: HierarchicalMemoryManager /** * The mode associated with this task. Persisted across sessions @@ -353,6 +355,11 @@ export class Task extends EventEmitter { this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit) + // Initialize memory manager asynchronously + this.initializeMemoryManager().catch((error) => { + console.error("Failed to initialize HierarchicalMemoryManager:", error) + }) + onCreated?.(this) if (startTask) { @@ -2135,4 +2142,35 @@ export class Task extends EventEmitter { public get cwd() { return this.workspacePath } + + private async initializeMemoryManager() { + const state = await this.providerRef.deref()?.getState() + const { enableHierarchicalMemory, hierarchicalMemoryFileNames } = state ?? {} + + this.memoryManager = new HierarchicalMemoryManager( + enableHierarchicalMemory ?? false, + hierarchicalMemoryFileNames ?? [], + ) + } + + public async injectHierarchicalMemory(messages: ApiMessage[]) { + if (messages.length === 0) return + + // Add messages to conversation history + for (const message of messages) { + await this.addToApiConversationHistory(message) + } + + // Notify UI about loaded memories + const provider = this.providerRef.deref() + if (provider) { + await provider.postMessageToWebview({ + type: "hierarchicalMemoryLoaded", + files: messages.map((msg) => ({ + path: msg.content.toString().match(/--- Memory from (.+) ---/)?.[1] || "", + content: msg.content.toString().replace(/--- Memory from .+ ---\n/, ""), + })), + }) + } + } } diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 01427f4d9d..f8872cd830 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -442,6 +442,18 @@ export async function readFileTool( maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, } = state ?? {} + // Load hierarchical memory for approved files + if (cline.memoryManager) { + const approvedFiles = fileResults.filter((result) => result.status === "approved") + for (const fileResult of approvedFiles) { + const fullPath = path.resolve(cline.cwd, fileResult.path) + const memoryMessages = await cline.memoryManager.loadFor(fullPath, cline.cwd) + if (memoryMessages.length > 0) { + await cline.injectHierarchicalMemory(memoryMessages) + } + } + } + // Then process only approved files for (const fileResult of fileResults) { // Skip files that weren't approved diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 980eb1f07b..100e340a1a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1611,6 +1611,8 @@ export class ClineProvider includeDiagnosticMessages, maxDiagnosticMessages, includeTaskHistoryInEnhance, + enableHierarchicalMemory, + hierarchicalMemoryFileNames, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1738,6 +1740,8 @@ export class ClineProvider includeDiagnosticMessages: includeDiagnosticMessages ?? true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? false, + enableHierarchicalMemory: enableHierarchicalMemory ?? false, + hierarchicalMemoryFileNames: hierarchicalMemoryFileNames ?? [], } } @@ -1925,6 +1929,9 @@ export class ClineProvider maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, // Add includeTaskHistoryInEnhance setting includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? false, + // Add hierarchical memory settings + enableHierarchicalMemory: stateValues.enableHierarchicalMemory ?? false, + hierarchicalMemoryFileNames: stateValues.hierarchicalMemoryFileNames ?? [], } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fdb7e90425..782c55b940 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2568,5 +2568,18 @@ export const webviewMessageHandler = async ( } break } + case "enableHierarchicalMemory": + await updateGlobalState("enableHierarchicalMemory", message.bool ?? false) + await provider.postStateToWebview() + break + case "hierarchicalMemoryFileNames": + // Ensure we have an array of strings + let fileNames: string[] = ["Roorules.md"] // default + if (Array.isArray(message.values)) { + fileNames = message.values.filter((v): v is string => typeof v === "string") + } + await updateGlobalState("hierarchicalMemoryFileNames", fileNames) + await provider.postStateToWebview() + break } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 930edeac73..43028b3290 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -120,6 +120,7 @@ export interface ExtensionMessage { | "showEditMessageDialog" | "commands" | "insertTextIntoTextarea" + | "hierarchicalMemoryLoaded" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -194,6 +195,7 @@ export interface ExtensionMessage { messageTs?: number context?: string commands?: Command[] + files?: Array<{ path: string; content: string }> // For hierarchicalMemoryLoaded } export type ExtensionState = Pick< @@ -270,6 +272,8 @@ export type ExtensionState = Pick< | "profileThresholds" | "includeDiagnosticMessages" | "maxDiagnosticMessages" + | "enableHierarchicalMemory" + | "hierarchicalMemoryFileNames" > & { version: string clineMessages: ClineMessage[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index cb8759d851..038aa0e15a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -210,6 +210,8 @@ export interface WebviewMessage { | "deleteCommand" | "createCommand" | "insertTextIntoTextarea" + | "enableHierarchicalMemory" + | "hierarchicalMemoryFileNames" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 1fe93eb470..a20ba9dd5b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -179,6 +179,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [isCondensing, setIsCondensing] = useState(false) const [showAnnouncementModal, setShowAnnouncementModal] = useState(false) + const [hierarchicalMemories, setHierarchicalMemories] = useState>([]) const everVisibleMessagesTsRef = useRef>( new LRUCache({ max: 250, @@ -450,6 +451,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -866,6 +869,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction {hasSystemPromptOverride && ( diff --git a/webview-ui/src/components/chat/HierarchicalMemoryModal.tsx b/webview-ui/src/components/chat/HierarchicalMemoryModal.tsx new file mode 100644 index 0000000000..a0c18b1a21 --- /dev/null +++ b/webview-ui/src/components/chat/HierarchicalMemoryModal.tsx @@ -0,0 +1,108 @@ +import { useState, memo } from "react" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@src/components/ui" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" + +interface HierarchicalMemoryModalProps { + isOpen: boolean + onClose: () => void + memories: Array<{ path: string; content: string }> +} + +const HierarchicalMemoryModal = ({ isOpen, onClose, memories }: HierarchicalMemoryModalProps) => { + const { t } = useAppTranslation() + const [selectedMemoryIndex, setSelectedMemoryIndex] = useState(0) + + if (!memories || memories.length === 0) { + return ( + !open && onClose()}> + + + {t("chat:hierarchicalMemory.title")} + {t("chat:hierarchicalMemory.noMemories")} + + + + ) + } + + const selectedMemory = memories[selectedMemoryIndex] + + return ( + !open && onClose()}> + + + {t("chat:hierarchicalMemory.title")} + + {t("chat:hierarchicalMemory.description", { count: memories.length })} + + + +
+ {/* Memory list sidebar */} +
+

+ {t("chat:hierarchicalMemory.loadedFiles")} +

+
+ {memories.map((memory, index) => { + const fileName = memory.path.split("/").pop() || memory.path + const dirPath = memory.path.substring(0, memory.path.lastIndexOf("/")) || "/" + + return ( + + ) + })} +
+
+ + {/* Memory content viewer */} +
+
+

+ {selectedMemory.path} +

+ { + navigator.clipboard.writeText(selectedMemory.content) + }} + title={t("chat:hierarchicalMemory.copyContent")}> + + +
+
+
+								{selectedMemory.content}
+							
+
+
+
+ +
+ {t("common:answers.close")} +
+
+
+ ) +} + +export default memo(HierarchicalMemoryModal) diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 603b6be3e0..6df6bb7cd5 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -10,60 +10,81 @@ import { useCopyToClipboard } from "@/utils/clipboard" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { IconButton } from "./IconButton" import { ShareButton } from "./ShareButton" +import HierarchicalMemoryModal from "./HierarchicalMemoryModal" interface TaskActionsProps { item?: HistoryItem buttonsDisabled: boolean + hierarchicalMemories?: Array<{ path: string; content: string }> } -export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { +export const TaskActions = ({ item, buttonsDisabled, hierarchicalMemories }: TaskActionsProps) => { const [deleteTaskId, setDeleteTaskId] = useState(null) + const [showMemoryModal, setShowMemoryModal] = useState(false) const { t } = useTranslation() const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() return ( -
- - vscode.postMessage({ type: "exportCurrentTask" })} - /> - {item?.task && ( + <> +
+ + {hierarchicalMemories && hierarchicalMemories.length > 0 && ( + setShowMemoryModal(true)} + /> + )} copyWithFeedback(item.task, e)} + iconClass="codicon-desktop-download" + title={t("chat:task.export")} + onClick={() => vscode.postMessage({ type: "exportCurrentTask" })} + /> + {item?.task && ( + copyWithFeedback(item.task, e)} + /> + )} + {!!item?.size && item.size > 0 && ( + <> +
+ { + e.stopPropagation() + + if (e.shiftKey) { + vscode.postMessage({ type: "deleteTaskWithId", text: item.id }) + } else { + setDeleteTaskId(item.id) + } + }} + /> + + {prettyBytes(item.size)} + +
+ {deleteTaskId && ( + !open && setDeleteTaskId(null)} + open + /> + )} + + )} +
+ {hierarchicalMemories && ( + setShowMemoryModal(false)} + memories={hierarchicalMemories} /> )} - {!!item?.size && item.size > 0 && ( - <> -
- { - e.stopPropagation() - - if (e.shiftKey) { - vscode.postMessage({ type: "deleteTaskWithId", text: item.id }) - } else { - setDeleteTaskId(item.id) - } - }} - /> - {prettyBytes(item.size)} -
- {deleteTaskId && ( - !open && setDeleteTaskId(null)} - open - /> - )} - - )} -
+ ) } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 1896df486b..ab32d0d54f 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -34,6 +34,7 @@ export interface TaskHeaderProps { handleCondenseContext: (taskId: string) => void onClose: () => void todos?: any[] + hierarchicalMemories?: Array<{ path: string; content: string }> } const TaskHeader = ({ @@ -48,6 +49,7 @@ const TaskHeader = ({ handleCondenseContext, onClose, todos, + hierarchicalMemories, }: TaskHeaderProps) => { const { t } = useTranslation() const { apiConfiguration, currentTaskItem } = useExtensionState() @@ -185,7 +187,13 @@ const TaskHeader = ({ )} - {!totalCost && } + {!totalCost && ( + + )} {((typeof cacheReads === "number" && cacheReads > 0) || @@ -213,7 +221,11 @@ const TaskHeader = ({ {t("chat:task.apiCost")} ${totalCost?.toFixed(2)} - + )} diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 88484e1d63..309e1af752 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -27,6 +27,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number writeDelayMs: number + enableHierarchicalMemory?: boolean + hierarchicalMemoryFileNames?: string[] setCachedStateField: SetCachedStateField< | "autoCondenseContext" | "autoCondenseContextPercent" @@ -41,6 +43,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "includeDiagnosticMessages" | "maxDiagnosticMessages" | "writeDelayMs" + | "enableHierarchicalMemory" + | "hierarchicalMemoryFileNames" > } @@ -60,6 +64,8 @@ export const ContextManagementSettings = ({ includeDiagnosticMessages, maxDiagnosticMessages, writeDelayMs, + enableHierarchicalMemory, + hierarchicalMemoryFileNames = [], className, ...props }: ContextManagementSettingsProps) => { @@ -356,6 +362,45 @@ export const ContextManagementSettings = ({ {t("settings:contextManagement.diagnostics.delayAfterWrite.description")} + +
+ setCachedStateField("enableHierarchicalMemory", e.target.checked)} + data-testid="enable-hierarchical-memory-checkbox"> + + +
+ {t("settings:contextManagement.hierarchicalMemory.enable.description")} +
+
+ + {enableHierarchicalMemory && ( +
+ + {t("settings:contextManagement.hierarchicalMemory.fileNames.label")} + + { + const names = e.target.value + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0) + setCachedStateField("hierarchicalMemoryFileNames", names) + }} + placeholder="Roorules.md, CLAUDE.md" + data-testid="hierarchical-memory-file-names-input" + /> +
+ {t("settings:contextManagement.hierarchicalMemory.fileNames.description")} +
+
+ )}
(({ onDone, t vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 }) vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages }) vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 }) + vscode.postMessage({ + type: "enableHierarchicalMemory", + bool: cachedState.enableHierarchicalMemory ?? false, + }) + vscode.postMessage({ + type: "hierarchicalMemoryFileNames", + values: cachedState.hierarchicalMemoryFileNames ?? ["Roorules.md"], + }) vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) vscode.postMessage({ type: "updateExperimental", values: experiments }) vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) @@ -682,6 +690,8 @@ const SettingsView = forwardRef(({ onDone, t includeDiagnosticMessages={includeDiagnosticMessages} maxDiagnosticMessages={maxDiagnosticMessages} writeDelayMs={writeDelayMs} + enableHierarchicalMemory={cachedState.enableHierarchicalMemory} + hierarchicalMemoryFileNames={cachedState.hierarchicalMemoryFileNames} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 48d55172a5..27e4abe0a2 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -378,5 +378,13 @@ "queuedMessages": { "title": "Queued Messages:", "clickToEdit": "Click to edit message" + }, + "hierarchicalMemory": { + "title": "Hierarchical Memory Files", + "description": "{{count}} memory file(s) loaded from parent directories", + "noMemories": "No hierarchical memory files are currently loaded", + "loadedFiles": "Loaded Files", + "copyContent": "Copy content to clipboard", + "viewMemory": "View loaded memory files" } } diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 46c15556c8..4c79208ae7 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -559,6 +559,17 @@ "profileDescription": "Custom threshold for this profile only (overrides global default)", "inheritDescription": "This profile inherits the global default threshold ({{threshold}}%)", "usesGlobal": "(uses global {{threshold}}%)" + }, + "hierarchicalMemory": { + "enable": { + "label": "Enable hierarchical memory", + "description": "When enabled, Roo will automatically load memory files (e.g., Roorules.md) from parent directories when reading files, providing additional context without manual inclusion." + }, + "fileNames": { + "label": "Memory file names", + "description": "Comma-separated list of file names to search for in parent directories. These files will be automatically included as context when reading files.", + "placeholder": "e.g., Roorules.md, CLAUDE.md, .context.md" + } } }, "terminal": {