diff --git a/core/index.d.ts b/core/index.d.ts index 8650c6ae2b8..4b52b66ebdb 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -1076,6 +1076,7 @@ export interface Tool { type: "function"; function: { name: string; + type?: string; description?: string; parameters?: Record; strict?: boolean | null; diff --git a/core/llm/llms/Bedrock.ts b/core/llm/llms/Bedrock.ts index ea47b63ae1f..f564188fbe1 100644 --- a/core/llm/llms/Bedrock.ts +++ b/core/llm/llms/Bedrock.ts @@ -281,8 +281,13 @@ class Bedrock extends BaseLLM { let toolConfig: undefined | ToolConfiguration = undefined; const availableTools = new Set(); if (supportsTools && options.tools && options.tools.length > 0) { - toolConfig = { - tools: options.tools.map((tool) => ({ + const isClaudeModel = options.model.includes("claude"); + + // For Claude models, filter out tools with a type property + // For other models, include all tools + const toolSpecs = options.tools + .filter((tool) => !isClaudeModel || !tool.function.type) + .map((tool) => ({ toolSpec: { name: tool.function.name, description: tool.function.description, @@ -290,12 +295,17 @@ class Bedrock extends BaseLLM { json: tool.function.parameters, }, }, - })), - } as ToolConfiguration; - const shouldCacheToolsConfig = this.completionOptions.promptCaching; - if (shouldCacheToolsConfig) { - toolConfig.tools!.push({ cachePoint: { type: "default" } }); + })); + + if (toolSpecs.length > 0) { + toolConfig = { tools: toolSpecs } as ToolConfiguration; + const shouldCacheToolsConfig = this.completionOptions.promptCaching; + if (shouldCacheToolsConfig) { + toolConfig.tools!.push({ cachePoint: { type: "default" } }); + } } + + // Add all tool names to availableTools set options.tools.forEach((tool) => { availableTools.add(tool.function.name); }); @@ -329,6 +339,14 @@ class Bedrock extends BaseLLM { .slice(0, 4), }, additionalModelRequestFields: { + tools: options.model.includes("claude") && options.tools + ? options.tools + .filter((tool) => tool.function.type) + .map((tool) => ({ + name: tool.function.name, + type: tool.function.type, + })) + : undefined, thinking: options.reasoning ? { type: "enabled", @@ -336,7 +354,7 @@ class Bedrock extends BaseLLM { } : undefined, anthropic_beta: options.model.includes("claude") - ? ["fine-grained-tool-streaming-2025-05-14"] + ? ["fine-grained-tool-streaming-2025-05-14","context-management-2025-06-27"] : undefined, }, }; diff --git a/core/tools/builtIn.ts b/core/tools/builtIn.ts index 47cd6769fcc..2eaf79c025f 100644 --- a/core/tools/builtIn.ts +++ b/core/tools/builtIn.ts @@ -16,6 +16,7 @@ export enum BuiltInToolNames { RequestRule = "request_rule", FetchUrlContent = "fetch_url_content", CodebaseTool = "codebase", + Memory = "memory", // excluded from allTools for now ViewRepoMap = "view_repo_map", diff --git a/core/tools/callTool.ts b/core/tools/callTool.ts index faec8a94b8c..2bbc9a65ef2 100644 --- a/core/tools/callTool.ts +++ b/core/tools/callTool.ts @@ -21,6 +21,7 @@ import { searchWebImpl } from "./implementations/searchWeb"; import { viewDiffImpl } from "./implementations/viewDiff"; import { viewRepoMapImpl } from "./implementations/viewRepoMap"; import { viewSubdirectoryImpl } from "./implementations/viewSubdirectory"; +import { memoryToolImpl } from "./implementations/memory"; import { safeParseToolCallArgs } from "./parseArgs"; async function callHttpTool( @@ -166,6 +167,8 @@ export async function callBuiltInTool( return await searchWebImpl(args, extras); case BuiltInToolNames.FetchUrlContent: return await fetchUrlContentImpl(args, extras); + case BuiltInToolNames.Memory: + return await memoryToolImpl(args, extras); case BuiltInToolNames.ViewDiff: return await viewDiffImpl(args, extras); case BuiltInToolNames.LSTool: diff --git a/core/tools/definitions/index.ts b/core/tools/definitions/index.ts index bf3dc02d96c..77a7a378faf 100644 --- a/core/tools/definitions/index.ts +++ b/core/tools/definitions/index.ts @@ -7,6 +7,7 @@ export { globSearchTool } from "./globSearch"; export { grepSearchTool } from "./grepSearch"; export { lsTool } from "./ls"; export { multiEditTool } from "./multiEdit"; +export { memoryTool } from "./memory"; export { readCurrentlyOpenFileTool } from "./readCurrentlyOpenFile"; export { readFileTool } from "./readFile"; diff --git a/core/tools/definitions/memory.ts b/core/tools/definitions/memory.ts new file mode 100644 index 00000000000..c1aa89163d1 --- /dev/null +++ b/core/tools/definitions/memory.ts @@ -0,0 +1,92 @@ +import { Tool } from "../.."; +import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn"; + +const COMMAND_OPTIONS = [ + "view", + "create", + "insert", + "str_replace", + "delete", + "rename", +]; + +export const memoryTool: Tool = { + type: "function", + displayTitle: "Memory", + wouldLikeTo: + "manage persistent memories using the {{{ command }}} command at {{{ path }}}", + isCurrently: + "managing persistent memories using the {{{ command }}} command at {{{ path }}}", + hasAlready: + "managed persistent memories using the {{{ command }}} command at {{{ path }}}", + readonly: false, + group: BUILT_IN_GROUP_NAME, + function: { + name: BuiltInToolNames.Memory, + type: "memory_20250818", + description: "Anthropic claude memory tool", + parameters: { + type: "object", + required: ["command"], + properties: { + command: { + type: "string", + enum: COMMAND_OPTIONS, + description: + "The memory operation to perform: view, create, insert, str_replace, delete, or rename.", + }, + path: { + type: "string", + description: + "Path within the /memories namespace targeted by the command. Must begin with /memories.", + }, + view_range: { + type: "array", + description: + "Optional [start, end] line range (1-indexed, inclusive) when viewing a memory file. Use -1 for end to read to EOF.", + items: { type: "number" }, + }, + file_text: { + type: "string", + description: "Content to write when creating a new memory file.", + }, + insert_line: { + type: "number", + description: + "0-based line number where insert_text should be added when using the insert command.", + }, + insert_text: { + type: "string", + description: "Text to insert when using the insert command.", + }, + old_str: { + type: "string", + description: + "Existing text to replace when using the str_replace command. Must appear exactly once.", + }, + new_str: { + type: "string", + description: + "Replacement text to use when the str_replace command is invoked.", + }, + old_path: { + type: "string", + description: "Existing path to rename when using the rename command.", + }, + new_path: { + type: "string", + description: "New path to rename to when using the rename command.", + }, + }, + }, + }, + systemMessageDescription: { + prefix: `To manage long-term memories stored under /memories, use the ${BuiltInToolNames.Memory} tool. Always provide a command (view, create, insert, str_replace, delete, or rename) and paths that begin with /memories. For example, to view the index file you could respond with:`, + exampleArgs: [ + ["command", "view"], + ["path", "/memories/index.md"], + ], + }, + defaultToolPolicy: "allowedWithoutPermission", + toolCallIcon: "ArchiveBoxIcon", +}; diff --git a/core/tools/implementations/memory.ts b/core/tools/implementations/memory.ts new file mode 100644 index 00000000000..a118f303184 --- /dev/null +++ b/core/tools/implementations/memory.ts @@ -0,0 +1,342 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { fileURLToPath, pathToFileURL } from "url"; + +import { ContextItem, ToolExtras } from "../.."; + +import { ToolImpl } from "."; +import { getStringArg } from "../parseArgs"; + +type MemoryCommand = + | "view" + | "create" + | "delete" + | "insert" + | "rename" + | "str_replace"; + +const MEMORY_DIR_NAME = "memory"; +const MEMORIES_ROOT_NAME = "memories"; + +async function ensureMemoryRoot(extras: ToolExtras): Promise { + const workspaceDirs = await extras.ide.getWorkspaceDirs(); + const workspaceDir = workspaceDirs[0]; + if (!workspaceDir) { + throw new Error( + "Memory tool requires a workspace. Please open a workspace so memories can be stored in its .continue directory.", + ); + } + + const workspacePath = workspaceDir.startsWith("file://") + ? fileURLToPath(workspaceDir) + : workspaceDir; + + const continueDir = path.join(workspacePath, ".continue"); + const memoryDir = path.join(continueDir, MEMORY_DIR_NAME); + const memoryRoot = path.join(memoryDir, MEMORIES_ROOT_NAME); + + await fs.mkdir(continueDir, { recursive: true }); + await fs.mkdir(memoryDir, { recursive: true }); + await fs.mkdir(memoryRoot, { recursive: true }); + return memoryRoot; +} + +function validateMemoryPath(memoryPath: string, memoryRoot: string): string { + if (!memoryPath.startsWith(`/${MEMORIES_ROOT_NAME}`)) { + throw new Error( + `Path must start with /${MEMORIES_ROOT_NAME}, got: ${memoryPath}`, + ); + } + + const relative = memoryPath + .slice(MEMORIES_ROOT_NAME.length + 1) + .replace(/^\/+/, ""); + const resolved = path.resolve(memoryRoot, relative.length ? relative : "."); + + const normalizedRoot = path.resolve(memoryRoot); + if (!resolved.startsWith(normalizedRoot)) { + throw new Error( + `Path ${memoryPath} would escape /${MEMORIES_ROOT_NAME} directory`, + ); + } + + return resolved; +} + +async function exists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +function formatContextItem( + command: MemoryCommand, + pathArg: string, + content: string, + fullPath: string, +): ContextItem { + return { + name: "Memory", + description: `${command.toUpperCase()} ${pathArg}`, + content, + uri: fullPath + ? { + type: "file", + value: pathToFileURL(fullPath).href, + } + : undefined, + } as ContextItem; +} + +function coerceLineNumber(raw: unknown): number { + if (typeof raw === "number" && Number.isInteger(raw)) { + return raw; + } + if (typeof raw === "string") { + const parsed = Number.parseInt(raw, 10); + if (Number.isInteger(parsed)) { + return parsed; + } + } + throw new Error( + `Invalid insert_line ${raw}. Must be an integer greater than or equal to 0`, + ); +} + +function coerceRange(value: unknown): [number, number] | undefined { + if (!Array.isArray(value) || value.length !== 2) { + return undefined; + } + const start = Number(value[0]); + const end = Number(value[1]); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return undefined; + } + return [start, end]; +} + +export const memoryToolImpl: ToolImpl = async (args, extras) => { + const command = getStringArg(args, "command") as MemoryCommand; + const memoryRoot = await ensureMemoryRoot(extras); + + switch (command) { + case "view": { + const pathArg = getStringArg(args, "path"); + const fullPath = validateMemoryPath(pathArg, memoryRoot); + + if (!(await exists(fullPath))) { + throw new Error(`Path not found: ${pathArg}`); + } + + const stat = await fs.stat(fullPath); + if (stat.isDirectory()) { + const entries = await fs.readdir(fullPath); + const items: string[] = []; + for (const entry of entries.sort()) { + if (entry.startsWith(".")) { + continue; + } + const entryPath = path.join(fullPath, entry); + const entryStat = await fs.stat(entryPath); + items.push(entryStat.isDirectory() ? `${entry}/` : entry); + } + const header = `Directory: ${pathArg}`; + const content = + items.length === 0 + ? header + : `${header}\n${items.map((item) => `- ${item}`).join("\n")}`; + return [formatContextItem(command, pathArg, content, fullPath)]; + } + + if (stat.isFile()) { + const fileContent = await fs.readFile(fullPath, "utf-8"); + const lines = fileContent.split("\n"); + let displayLines = lines; + let startNumber = 1; + const range = coerceRange(args.view_range); + if (range) { + const [start, end] = range; + const startIndex = Math.max(1, Math.trunc(start)) - 1; + const endIndex = + end === -1 + ? lines.length + : Math.max(startIndex + 1, Math.trunc(end)); + displayLines = lines.slice(startIndex, endIndex); + startNumber = startIndex + 1; + } + const numbered = displayLines + .map( + (line, index) => + `${String(index + startNumber).padStart(4, " ")}: ${line}`, + ) + .join("\n"); + return [formatContextItem(command, pathArg, numbered, fullPath)]; + } + + throw new Error(`Path not found: ${pathArg}`); + } + case "create": { + const pathArg = getStringArg(args, "path"); + const fullPath = validateMemoryPath(pathArg, memoryRoot); + const dir = path.dirname(fullPath); + + if (!(await exists(dir))) { + await fs.mkdir(dir, { recursive: true }); + throw new Error(`Path not found: ${pathArg}`); + } + + await fs.writeFile( + fullPath, + getStringArg(args, "file_text", true), + "utf-8", + ); + return [ + formatContextItem( + command, + pathArg, + `File created successfully at ${pathArg}`, + fullPath, + ), + ]; + } + case "str_replace": { + const pathArg = getStringArg(args, "path"); + const fullPath = validateMemoryPath(pathArg, memoryRoot); + + if (!(await exists(fullPath))) { + throw new Error(`File not found: ${pathArg}`); + } + const stat = await fs.stat(fullPath); + if (!stat.isFile()) { + throw new Error(`Path is not a file: ${pathArg}`); + } + + const oldStr = getStringArg(args, "old_str", true); + const newStr = getStringArg(args, "new_str", true); + const current = await fs.readFile(fullPath, "utf-8"); + const count = current.split(oldStr).length - 1; + + if (count === 0) { + throw new Error(`Text not found in ${pathArg}`); + } + if (count > 1) { + throw new Error( + `Text appears ${count} times in ${pathArg}. Must be unique.`, + ); + } + + await fs.writeFile(fullPath, current.replace(oldStr, newStr), "utf-8"); + return [ + formatContextItem( + command, + pathArg, + `File ${pathArg} has been edited`, + fullPath, + ), + ]; + } + case "insert": { + const pathArg = getStringArg(args, "path"); + const fullPath = validateMemoryPath(pathArg, memoryRoot); + + if (!(await exists(fullPath))) { + throw new Error(`File not found: ${pathArg}`); + } + const stat = await fs.stat(fullPath); + if (!stat.isFile()) { + throw new Error(`Path is not a file: ${pathArg}`); + } + + const insertLine = coerceLineNumber(args.insert_line); + const insertText = getStringArg(args, "insert_text", true); + const current = await fs.readFile(fullPath, "utf-8"); + const lines = current.split("\n"); + + if (insertLine < 0 || insertLine > lines.length) { + throw new Error( + `Invalid insert_line ${args.insert_line}. Must be 0-${lines.length}`, + ); + } + + lines.splice(insertLine, 0, insertText.replace(/\n$/, "")); + await fs.writeFile(fullPath, lines.join("\n"), "utf-8"); + return [ + formatContextItem( + command, + pathArg, + `Text inserted at line ${insertLine} in ${pathArg}`, + fullPath, + ), + ]; + } + case "delete": { + const pathArg = getStringArg(args, "path"); + const fullPath = validateMemoryPath(pathArg, memoryRoot); + + if (pathArg === `/${MEMORIES_ROOT_NAME}`) { + throw new Error( + `Cannot delete the /${MEMORIES_ROOT_NAME} directory itself`, + ); + } + + if (!(await exists(fullPath))) { + throw new Error(`Path not found: ${pathArg}`); + } + + const stat = await fs.stat(fullPath); + if (stat.isFile()) { + await fs.unlink(fullPath); + return [ + formatContextItem(command, pathArg, `File deleted: ${pathArg}`, ""), + ]; + } + if (stat.isDirectory()) { + await fs.rm(fullPath, { recursive: true, force: true }); + return [ + formatContextItem( + command, + pathArg, + `Directory deleted: ${pathArg}`, + "", + ), + ]; + } + + throw new Error(`Path not found: ${pathArg}`); + } + case "rename": { + const oldPath = getStringArg(args, "old_path"); + const newPath = getStringArg(args, "new_path"); + const oldFull = validateMemoryPath(oldPath, memoryRoot); + const newFull = validateMemoryPath(newPath, memoryRoot); + + if (!(await exists(oldFull))) { + throw new Error(`Source path not found: ${oldPath}`); + } + + if (await exists(newFull)) { + throw new Error(`Destination already exists: ${newPath}`); + } + + const newDir = path.dirname(newFull); + if (!(await exists(newDir))) { + await fs.mkdir(newDir, { recursive: true }); + } + + await fs.rename(oldFull, newFull); + return [ + formatContextItem( + command, + `${oldPath} -> ${newPath}`, + `Renamed ${oldPath} to ${newPath}`, + newFull, + ), + ]; + } + default: + throw new Error(`Unsupported memory command: ${command}`); + } +}; diff --git a/core/tools/index.ts b/core/tools/index.ts index e021a601c88..6f0ba3cc8b0 100644 --- a/core/tools/index.ts +++ b/core/tools/index.ts @@ -15,6 +15,14 @@ export const getBaseToolDefinitions = () => [ toolDefinitions.fetchUrlContentTool, ]; +const isClaudeModel = (modelName: string | undefined): boolean => { + if (!modelName) { + return false; + } + + return modelName.toLowerCase().includes("claude"); +}; + export const getConfigDependentToolDefinitions = ( params: ConfigDependentToolParams, ): Tool[] => { @@ -28,6 +36,10 @@ export const getConfigDependentToolDefinitions = ( tools.push(toolDefinitions.searchWebTool); } + if (isClaudeModel(modelName)) { + tools.push(toolDefinitions.memoryTool); + } + if (enableExperimentalTools) { tools.push( toolDefinitions.viewRepoMapTool, diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index fda429563c0..8ce12e06edf 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -20,6 +20,7 @@ import { exitTool } from "./exit.js"; import { fetchTool } from "./fetch.js"; import { listFilesTool } from "./listFiles.js"; import { multiEditTool } from "./multiEdit.js"; +import { memoryTool } from "./memory.js"; import { readFileTool } from "./readFile.js"; import { runTerminalCommandTool } from "./runTerminalCommand.js"; import { searchCodeTool } from "./searchCode.js"; @@ -49,6 +50,10 @@ const BASE_BUILTIN_TOOLS: Tool[] = [ writeChecklistTool, ]; +const MEMORY_TOOL_INSERT_INDEX = BASE_BUILTIN_TOOLS.findIndex( + (tool) => tool.name === fetchTool.name, +); + // Export BUILTIN_TOOLS as the base set of tools // Dynamic tools (like exit tool in headless mode) are added separately export const BUILTIN_TOOLS: Tool[] = BASE_BUILTIN_TOOLS; @@ -106,10 +111,56 @@ function shouldExcludeEditTool(): boolean { return false; } +function isClaudeModelValue(value?: string | null): boolean { + return typeof value === "string" && value.toLowerCase().includes("claude"); +} + +function shouldIncludeMemoryTool(): boolean { + try { + const modelServiceResult = getServiceSync( + SERVICE_NAMES.MODEL, + ); + + if ( + modelServiceResult.state === "ready" && + modelServiceResult.value?.model + ) { + const { name, provider, model } = modelServiceResult.value.model; + + const isClaudeModel = + isClaudeModelValue(name) || isClaudeModelValue(model); + + if (isClaudeModel) { + logger.debug("Enabling Claude-only memory tool", { + provider, + name, + model, + }); + } + + return isClaudeModel; + } + } catch (error) { + logger.debug("Error checking model for Claude-only memory tool", { + error, + }); + } + + return false; +} + // Get all builtin tools including dynamic ones, with capability-based filtering export function getAllBuiltinTools(): Tool[] { let builtinTools = [...BUILTIN_TOOLS]; + if (shouldIncludeMemoryTool()) { + const insertAt = + MEMORY_TOOL_INSERT_INDEX === -1 + ? builtinTools.length + : MEMORY_TOOL_INSERT_INDEX; + builtinTools.splice(insertAt, 0, memoryTool); + } + // Apply capability-based filtering for edit tools // If model is capable, exclude editTool in favor of multiEditTool if (shouldExcludeEditTool()) { diff --git a/extensions/cli/src/tools/memory.ts b/extensions/cli/src/tools/memory.ts new file mode 100644 index 00000000000..cc205b0c9f7 --- /dev/null +++ b/extensions/cli/src/tools/memory.ts @@ -0,0 +1,397 @@ +import { promises as fs } from "fs"; +import path from "path"; + +import { Tool } from "./types.js"; + +type MemoryCommand = + | "view" + | "create" + | "delete" + | "insert" + | "rename" + | "str_replace"; + +interface BaseMemoryArgs { + command: MemoryCommand; +} + +interface ViewArgs extends BaseMemoryArgs { + command: "view"; + path: string; + view_range?: Array; +} + +interface CreateArgs extends BaseMemoryArgs { + command: "create"; + path: string; + file_text: string; +} + +interface DeleteArgs extends BaseMemoryArgs { + command: "delete"; + path: string; +} + +interface InsertArgs extends BaseMemoryArgs { + command: "insert"; + path: string; + insert_line: number | string; + insert_text: string; +} + +interface RenameArgs extends BaseMemoryArgs { + command: "rename"; + old_path: string; + new_path: string; +} + +interface StrReplaceArgs extends BaseMemoryArgs { + command: "str_replace"; + path: string; + old_str: string; + new_str: string; +} + +type MemoryArgs = + | ViewArgs + | CreateArgs + | DeleteArgs + | InsertArgs + | RenameArgs + | StrReplaceArgs; + +const MEMORY_DIR_NAME = "memory"; +const MEMORIES_ROOT_NAME = "memories"; + +async function ensureMemoryRoot(): Promise { + const workspaceContinueDir = path.join(process.cwd(), ".continue"); + const memoryDir = path.join(workspaceContinueDir, MEMORY_DIR_NAME); + const memoryRoot = path.join(memoryDir, MEMORIES_ROOT_NAME); + + await fs.mkdir(workspaceContinueDir, { recursive: true }); + await fs.mkdir(memoryDir, { recursive: true }); + await fs.mkdir(memoryRoot, { recursive: true }); + return memoryRoot; +} + +function validatePath(memoryPath: string, memoryRoot: string): string { + if (!memoryPath.startsWith(`/${MEMORIES_ROOT_NAME}`)) { + throw new Error( + `Path must start with /${MEMORIES_ROOT_NAME}, got: ${memoryPath}`, + ); + } + + const relative = memoryPath + .slice(MEMORIES_ROOT_NAME.length + 1) + .replace(/^\/+/, ""); + const resolved = path.resolve(memoryRoot, relative.length ? relative : "."); + const normalizedRoot = path.resolve(memoryRoot); + + if (!resolved.startsWith(normalizedRoot)) { + throw new Error( + `Path ${memoryPath} would escape /${MEMORIES_ROOT_NAME} directory`, + ); + } + + return resolved; +} + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function handleView(args: ViewArgs, memoryRoot: string): Promise { + const fullPath = validatePath(args.path, memoryRoot); + + if (!(await exists(fullPath))) { + throw new Error(`Path not found: ${args.path}`); + } + + const stat = await fs.stat(fullPath); + + if (stat.isDirectory()) { + const entries = await fs.readdir(fullPath); + const items = [] as string[]; + for (const entry of entries.sort()) { + if (entry.startsWith(".")) { + continue; + } + const entryPath = path.join(fullPath, entry); + const entryStat = await fs.stat(entryPath); + items.push(entryStat.isDirectory() ? `${entry}/` : entry); + } + + const header = `Directory: ${args.path}`; + if (items.length === 0) { + return `${header}`; + } + return `${header}\n${items.map((item) => `- ${item}`).join("\n")}`; + } + + if (stat.isFile()) { + const content = await fs.readFile(fullPath, "utf-8"); + const lines = content.split("\n"); + let displayLines = lines; + let startNumber = 1; + + if (Array.isArray(args.view_range) && args.view_range.length === 2) { + const start = Number(args.view_range[0]); + const endRaw = Number(args.view_range[1]); + const startIndex = Number.isFinite(start) ? Math.max(1, start) - 1 : 0; + const endIndex = + Number.isFinite(endRaw) && endRaw !== -1 + ? Math.max(startIndex + 1, endRaw) + : lines.length; + displayLines = lines.slice(startIndex, endIndex); + startNumber = startIndex + 1; + } + + return displayLines + .map( + (line, index) => + `${String(index + startNumber).padStart(4, " ")}: ${line}`, + ) + .join("\n"); + } + + throw new Error(`Path not found: ${args.path}`); +} + +async function handleCreate( + args: CreateArgs, + memoryRoot: string, +): Promise { + const fullPath = validatePath(args.path, memoryRoot); + const dir = path.dirname(fullPath); + + if (!(await exists(dir))) { + await fs.mkdir(dir, { recursive: true }); + throw new Error(`Path not found: ${args.path}`); + } + + await fs.writeFile(fullPath, args.file_text, "utf-8"); + return `File created successfully at ${args.path}`; +} + +async function handleStrReplace( + args: StrReplaceArgs, + memoryRoot: string, +): Promise { + const fullPath = validatePath(args.path, memoryRoot); + + if (!(await exists(fullPath))) { + throw new Error(`File not found: ${args.path}`); + } + + const stat = await fs.stat(fullPath); + if (!stat.isFile()) { + throw new Error(`Path is not a file: ${args.path}`); + } + + const content = await fs.readFile(fullPath, "utf-8"); + const occurrences = content.split(args.old_str).length - 1; + + if (occurrences === 0) { + throw new Error(`Text not found in ${args.path}`); + } + if (occurrences > 1) { + throw new Error( + `Text appears ${occurrences} times in ${args.path}. Must be unique.`, + ); + } + + await fs.writeFile( + fullPath, + content.replace(args.old_str, args.new_str), + "utf-8", + ); + return `File ${args.path} has been edited`; +} + +async function handleInsert( + args: InsertArgs, + memoryRoot: string, +): Promise { + const fullPath = validatePath(args.path, memoryRoot); + + if (!(await exists(fullPath))) { + throw new Error(`File not found: ${args.path}`); + } + + const stat = await fs.stat(fullPath); + if (!stat.isFile()) { + throw new Error(`Path is not a file: ${args.path}`); + } + + const content = await fs.readFile(fullPath, "utf-8"); + const lines = content.split("\n"); + const lineNumber = Number(args.insert_line); + + if ( + !Number.isInteger(lineNumber) || + lineNumber < 0 || + lineNumber > lines.length + ) { + throw new Error( + `Invalid insert_line ${args.insert_line}. Must be 0-${lines.length}`, + ); + } + + lines.splice(lineNumber, 0, args.insert_text.replace(/\n$/, "")); + await fs.writeFile(fullPath, lines.join("\n"), "utf-8"); + return `Text inserted at line ${lineNumber} in ${args.path}`; +} + +async function handleDelete( + args: DeleteArgs, + memoryRoot: string, +): Promise { + const fullPath = validatePath(args.path, memoryRoot); + + if (args.path === `/${MEMORIES_ROOT_NAME}`) { + throw new Error( + `Cannot delete the /${MEMORIES_ROOT_NAME} directory itself`, + ); + } + + if (!(await exists(fullPath))) { + throw new Error(`Path not found: ${args.path}`); + } + + const stat = await fs.stat(fullPath); + if (stat.isFile()) { + await fs.unlink(fullPath); + return `File deleted: ${args.path}`; + } + if (stat.isDirectory()) { + await fs.rm(fullPath, { recursive: true, force: true }); + return `Directory deleted: ${args.path}`; + } + + throw new Error(`Path not found: ${args.path}`); +} + +async function handleRename( + args: RenameArgs, + memoryRoot: string, +): Promise { + const oldFullPath = validatePath(args.old_path, memoryRoot); + const newFullPath = validatePath(args.new_path, memoryRoot); + + if (!(await exists(oldFullPath))) { + throw new Error(`Source path not found: ${args.old_path}`); + } + + if (await exists(newFullPath)) { + throw new Error(`Destination already exists: ${args.new_path}`); + } + + const newDir = path.dirname(newFullPath); + if (!(await exists(newDir))) { + await fs.mkdir(newDir, { recursive: true }); + } + + await fs.rename(oldFullPath, newFullPath); + return `Renamed ${args.old_path} to ${args.new_path}`; +} + +async function executeMemoryCommand(args: MemoryArgs): Promise { + const memoryRoot = await ensureMemoryRoot(); + + switch (args.command) { + case "view": + return handleView(args, memoryRoot); + case "create": + return handleCreate(args, memoryRoot); + case "delete": + return handleDelete(args, memoryRoot); + case "insert": + return handleInsert(args, memoryRoot); + case "rename": + return handleRename(args, memoryRoot); + case "str_replace": + return handleStrReplace(args, memoryRoot); + default: + throw new Error( + `Unsupported command: ${(args as BaseMemoryArgs).command}`, + ); + } +} + +export const memoryTool: Tool = { + name: "memory", + displayName: "Memory", + description: + "Interact with persistent /memories storage. Supports view, create, insert, str_replace, delete, and rename commands.", + parameters: { + type: "object", + required: ["command"], + properties: { + command: { + type: "string", + description: + "The memory command to run. One of view, create, insert, str_replace, delete, or rename.", + }, + path: { + type: "string", + description: "Target path inside the /memories namespace.", + }, + view_range: { + type: "array", + description: "Optional [start, end] line range when viewing a file.", + items: { type: "number" }, + }, + file_text: { + type: "string", + description: "File contents used with the create command.", + }, + insert_line: { + type: "number", + description: + "Line index (0-based) where new text should be inserted for the insert command.", + }, + insert_text: { + type: "string", + description: + "Text to insert at the provided line when using the insert command.", + }, + old_path: { + type: "string", + description: "Existing path to rename when using the rename command.", + }, + new_path: { + type: "string", + description: "New path to rename to when using the rename command.", + }, + old_str: { + type: "string", + description: "String to search for with the str_replace command.", + }, + new_str: { + type: "string", + description: "Replacement string used with the str_replace command.", + }, + }, + }, + readonly: false, + isBuiltIn: true, + run: async (rawArgs: any): Promise => { + try { + if (!rawArgs || typeof rawArgs.command !== "string") { + throw new Error( + "The `command` argument is required for the memory tool", + ); + } + + const command = rawArgs.command as MemoryCommand; + return await executeMemoryCommand({ ...rawArgs, command } as MemoryArgs); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } + }, +};