mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
feat: implement hierarchical memory management system
- Add HierarchicalMemoryManager class to recursively load memory files - Update ApiMessage interface with isHierarchicalMemory flag - Integrate memory loading into readFileTool - Add UI controls in ContextManagementSettings - Create HierarchicalMemoryModal for viewing loaded memories - Add memory view button to TaskActions - Implement context compression compatibility - Add comprehensive tests for HierarchicalMemoryManager - Add translation keys for new UI elements Fixes #6602
This commit is contained in:
parent
8513263a67
commit
a2e6ec52ee
18 changed files with 661 additions and 44 deletions
|
|
@ -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<typeof globalSettingsSchema>
|
||||
|
|
|
|||
61
src/core/memory/HierarchicalMemoryManager.ts
Normal file
61
src/core/memory/HierarchicalMemoryManager.ts
Normal file
|
|
@ -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<string>()
|
||||
|
||||
constructor(
|
||||
private readonly enabled: boolean,
|
||||
private readonly names: string[],
|
||||
) {}
|
||||
|
||||
async loadFor(filePath: string, root: string): Promise<ApiMessage[]> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
253
src/core/memory/__tests__/HierarchicalMemoryManager.test.ts
Normal file
253
src/core/memory/__tests__/HierarchicalMemoryManager.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<TaskEvents> {
|
|||
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<TaskEvents> {
|
|||
|
||||
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<TaskEvents> {
|
|||
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/, ""),
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@ export interface WebviewMessage {
|
|||
| "deleteCommand"
|
||||
| "createCommand"
|
||||
| "insertTextIntoTextarea"
|
||||
| "enableHierarchicalMemory"
|
||||
| "hierarchicalMemoryFileNames"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const [showCheckpointWarning, setShowCheckpointWarning] = useState<boolean>(false)
|
||||
const [isCondensing, setIsCondensing] = useState<boolean>(false)
|
||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
|
||||
const [hierarchicalMemories, setHierarchicalMemories] = useState<Array<{ path: string; content: string }>>([])
|
||||
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
|
||||
new LRUCache({
|
||||
max: 250,
|
||||
|
|
@ -450,6 +451,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setMessageQueue([])
|
||||
// Clear retry counts
|
||||
retryCountRef.current.clear()
|
||||
// Clear hierarchical memories for new task
|
||||
setHierarchicalMemories([])
|
||||
}, [task?.ts])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -866,6 +869,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setIsCondensing(false)
|
||||
}
|
||||
break
|
||||
case "hierarchicalMemoryLoaded":
|
||||
if (message.files) {
|
||||
setHierarchicalMemories(message.files)
|
||||
}
|
||||
break
|
||||
}
|
||||
// textAreaRef.current is not explicitly required here since React
|
||||
// guarantees that ref will be stable across re-renders, and we're
|
||||
|
|
@ -1767,6 +1775,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
handleCondenseContext={handleCondenseContext}
|
||||
onClose={handleTaskCloseButtonClick}
|
||||
todos={latestTodos}
|
||||
hierarchicalMemories={hierarchicalMemories}
|
||||
/>
|
||||
|
||||
{hasSystemPromptOverride && (
|
||||
|
|
|
|||
108
webview-ui/src/components/chat/HierarchicalMemoryModal.tsx
Normal file
108
webview-ui/src/components/chat/HierarchicalMemoryModal.tsx
Normal file
|
|
@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("chat:hierarchicalMemory.title")}</DialogTitle>
|
||||
<DialogDescription>{t("chat:hierarchicalMemory.noMemories")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedMemory = memories[selectedMemoryIndex]
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("chat:hierarchicalMemory.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("chat:hierarchicalMemory.description", { count: memories.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* Memory list sidebar */}
|
||||
<div className="w-64 flex-shrink-0 border-r border-vscode-editorGroup-border pr-4">
|
||||
<h3 className="text-sm font-medium mb-2 text-vscode-foreground">
|
||||
{t("chat:hierarchicalMemory.loadedFiles")}
|
||||
</h3>
|
||||
<div className="space-y-1 overflow-y-auto max-h-[calc(80vh-200px)]">
|
||||
{memories.map((memory, index) => {
|
||||
const fileName = memory.path.split("/").pop() || memory.path
|
||||
const dirPath = memory.path.substring(0, memory.path.lastIndexOf("/")) || "/"
|
||||
|
||||
return (
|
||||
<button
|
||||
key={memory.path}
|
||||
onClick={() => setSelectedMemoryIndex(index)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-sm transition-colors ${
|
||||
index === selectedMemoryIndex
|
||||
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
|
||||
: "hover:bg-vscode-list-hoverBackground"
|
||||
}`}>
|
||||
<div className="font-medium truncate" title={fileName}>
|
||||
{fileName}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-vscode-descriptionForeground truncate"
|
||||
title={dirPath}>
|
||||
{dirPath}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory content viewer */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3
|
||||
className="text-sm font-medium text-vscode-foreground truncate"
|
||||
title={selectedMemory.path}>
|
||||
{selectedMemory.path}
|
||||
</h3>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(selectedMemory.content)
|
||||
}}
|
||||
title={t("chat:hierarchicalMemory.copyContent")}>
|
||||
<span className="codicon codicon-copy"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-vscode-editor-background rounded border border-vscode-editorGroup-border p-4">
|
||||
<pre className="text-xs font-mono text-vscode-editor-foreground whitespace-pre-wrap">
|
||||
{selectedMemory.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<VSCodeButton onClick={onClose}>{t("common:answers.close")}</VSCodeButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HierarchicalMemoryModal)
|
||||
|
|
@ -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<string | null>(null)
|
||||
const [showMemoryModal, setShowMemoryModal] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard()
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
<ShareButton item={item} disabled={false} />
|
||||
<IconButton
|
||||
iconClass="codicon-desktop-download"
|
||||
title={t("chat:task.export")}
|
||||
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
|
||||
/>
|
||||
{item?.task && (
|
||||
<>
|
||||
<div className="flex flex-row gap-1">
|
||||
<ShareButton item={item} disabled={false} />
|
||||
{hierarchicalMemories && hierarchicalMemories.length > 0 && (
|
||||
<IconButton
|
||||
iconClass="codicon-library"
|
||||
title={t("chat:hierarchicalMemory.viewMemory")}
|
||||
onClick={() => setShowMemoryModal(true)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
iconClass={showCopyFeedback ? "codicon-check" : "codicon-copy"}
|
||||
title={t("history:copyPrompt")}
|
||||
onClick={(e) => copyWithFeedback(item.task, e)}
|
||||
iconClass="codicon-desktop-download"
|
||||
title={t("chat:task.export")}
|
||||
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
|
||||
/>
|
||||
{item?.task && (
|
||||
<IconButton
|
||||
iconClass={showCopyFeedback ? "codicon-check" : "codicon-copy"}
|
||||
title={t("history:copyPrompt")}
|
||||
onClick={(e) => copyWithFeedback(item.task, e)}
|
||||
/>
|
||||
)}
|
||||
{!!item?.size && item.size > 0 && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<IconButton
|
||||
iconClass="codicon-trash"
|
||||
title={t("chat:task.delete")}
|
||||
disabled={buttonsDisabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.shiftKey) {
|
||||
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
|
||||
} else {
|
||||
setDeleteTaskId(item.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="ml-1 text-xs text-vscode-foreground opacity-85">
|
||||
{prettyBytes(item.size)}
|
||||
</span>
|
||||
</div>
|
||||
{deleteTaskId && (
|
||||
<DeleteTaskDialog
|
||||
taskId={deleteTaskId}
|
||||
onOpenChange={(open) => !open && setDeleteTaskId(null)}
|
||||
open
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hierarchicalMemories && (
|
||||
<HierarchicalMemoryModal
|
||||
isOpen={showMemoryModal}
|
||||
onClose={() => setShowMemoryModal(false)}
|
||||
memories={hierarchicalMemories}
|
||||
/>
|
||||
)}
|
||||
{!!item?.size && item.size > 0 && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<IconButton
|
||||
iconClass="codicon-trash"
|
||||
title={t("chat:task.delete")}
|
||||
disabled={buttonsDisabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.shiftKey) {
|
||||
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
|
||||
} else {
|
||||
setDeleteTaskId(item.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="ml-1 text-xs text-vscode-foreground opacity-85">{prettyBytes(item.size)}</span>
|
||||
</div>
|
||||
{deleteTaskId && (
|
||||
<DeleteTaskDialog
|
||||
taskId={deleteTaskId}
|
||||
onOpenChange={(open) => !open && setDeleteTaskId(null)}
|
||||
open
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!totalCost && <TaskActions item={currentTaskItem} buttonsDisabled={buttonsDisabled} />}
|
||||
{!totalCost && (
|
||||
<TaskActions
|
||||
item={currentTaskItem}
|
||||
buttonsDisabled={buttonsDisabled}
|
||||
hierarchicalMemories={hierarchicalMemories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{((typeof cacheReads === "number" && cacheReads > 0) ||
|
||||
|
|
@ -213,7 +221,11 @@ const TaskHeader = ({
|
|||
<span className="font-bold">{t("chat:task.apiCost")}</span>
|
||||
<span>${totalCost?.toFixed(2)}</span>
|
||||
</div>
|
||||
<TaskActions item={currentTaskItem} buttonsDisabled={buttonsDisabled} />
|
||||
<TaskActions
|
||||
item={currentTaskItem}
|
||||
buttonsDisabled={buttonsDisabled}
|
||||
hierarchicalMemories={hierarchicalMemories}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
includeDiagnosticMessages?: boolean
|
||||
maxDiagnosticMessages?: number
|
||||
writeDelayMs: number
|
||||
enableHierarchicalMemory?: boolean
|
||||
hierarchicalMemoryFileNames?: string[]
|
||||
setCachedStateField: SetCachedStateField<
|
||||
| "autoCondenseContext"
|
||||
| "autoCondenseContextPercent"
|
||||
|
|
@ -41,6 +43,8 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
|||
| "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")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<VSCodeCheckbox
|
||||
checked={enableHierarchicalMemory}
|
||||
onChange={(e: any) => setCachedStateField("enableHierarchicalMemory", e.target.checked)}
|
||||
data-testid="enable-hierarchical-memory-checkbox">
|
||||
<label className="block font-medium mb-1">
|
||||
{t("settings:contextManagement.hierarchicalMemory.enable.label")}
|
||||
</label>
|
||||
</VSCodeCheckbox>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
|
||||
{t("settings:contextManagement.hierarchicalMemory.enable.description")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enableHierarchicalMemory && (
|
||||
<div>
|
||||
<span className="block font-medium mb-1">
|
||||
{t("settings:contextManagement.hierarchicalMemory.fileNames.label")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
className="w-full bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border px-2 py-1 rounded"
|
||||
value={hierarchicalMemoryFileNames.join(", ")}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1">
|
||||
{t("settings:contextManagement.hierarchicalMemory.fileNames.description")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
<Section className="pt-2">
|
||||
<VSCodeCheckbox
|
||||
|
|
|
|||
|
|
@ -331,6 +331,14 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
includeDiagnosticMessages={includeDiagnosticMessages}
|
||||
maxDiagnosticMessages={maxDiagnosticMessages}
|
||||
writeDelayMs={writeDelayMs}
|
||||
enableHierarchicalMemory={cachedState.enableHierarchicalMemory}
|
||||
hierarchicalMemoryFileNames={cachedState.hierarchicalMemoryFileNames}
|
||||
setCachedStateField={setCachedStateField}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue