From 1e1fa410c353d205f01409fe6d8ed63c4e969fbf Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 14:41:08 +0800 Subject: [PATCH] refactor(tools): remove KV memory tools in favor of file-based memory Memory is now managed through profile files (memory.md, memory/*.md) using standard read/edit tools, following OpenClaw's file-first approach. Changes: - Remove memory/ folder with KV-based memory tools - Remove group:memory from tool groups - Update system prompt to remove memory tool references - Update README docs to reflect file-based memory approach Agents use workspace.md instructions to manage memory via file operations. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/ipc/agent.ts | 2 - src/agent/system-prompt/builder.test.ts | 8 +- src/agent/system-prompt/sections.test.ts | 7 +- src/agent/system-prompt/sections.ts | 23 --- src/agent/tools.ts | 32 +-- src/agent/tools/README.md | 41 ++-- src/agent/tools/README.zh-CN.md | 41 ++-- src/agent/tools/groups.ts | 3 - src/agent/tools/memory/index.ts | 6 - src/agent/tools/memory/memory-tools.ts | 175 ----------------- src/agent/tools/memory/storage.test.ts | 224 --------------------- src/agent/tools/memory/storage.ts | 240 ----------------------- src/agent/tools/memory/types.ts | 67 ------- 13 files changed, 42 insertions(+), 827 deletions(-) delete mode 100644 src/agent/tools/memory/index.ts delete mode 100644 src/agent/tools/memory/memory-tools.ts delete mode 100644 src/agent/tools/memory/storage.test.ts delete mode 100644 src/agent/tools/memory/storage.ts delete mode 100644 src/agent/tools/memory/types.ts diff --git a/apps/desktop/electron/ipc/agent.ts b/apps/desktop/electron/ipc/agent.ts index 61622286..575a290b 100644 --- a/apps/desktop/electron/ipc/agent.ts +++ b/apps/desktop/electron/ipc/agent.ts @@ -12,7 +12,6 @@ const TOOL_GROUPS: Record = { 'group:fs': ['read', 'write', 'edit', 'glob'], 'group:runtime': ['exec', 'process'], 'group:web': ['web_search', 'web_fetch'], - 'group:memory': ['memory_get', 'memory_set', 'memory_delete', 'memory_list'], 'group:subagent': ['sessions_spawn'], } @@ -21,7 +20,6 @@ const ALL_KNOWN_TOOLS = [ ...TOOL_GROUPS['group:fs'], ...TOOL_GROUPS['group:runtime'], ...TOOL_GROUPS['group:web'], - ...TOOL_GROUPS['group:memory'], ...TOOL_GROUPS['group:subagent'], ] diff --git a/src/agent/system-prompt/builder.test.ts b/src/agent/system-prompt/builder.test.ts index 09a29467..426e857a 100644 --- a/src/agent/system-prompt/builder.test.ts +++ b/src/agent/system-prompt/builder.test.ts @@ -10,7 +10,7 @@ const PROFILE = { config: { name: "TestAgent" }, }; -const TOOLS = ["read", "write", "edit", "glob", "exec", "memory_get", "memory_set", "sessions_spawn", "web_search"]; +const TOOLS = ["read", "write", "edit", "glob", "exec", "sessions_spawn", "web_search"]; describe("buildSystemPrompt", () => { // ── Full mode ───────────────────────────────────────────────────────── @@ -42,12 +42,6 @@ describe("buildSystemPrompt", () => { expect(result).toContain("## Tool Call Style"); }); - it("full mode includes memory section when memory tools present", () => { - const result = buildSystemPrompt({ mode: "full", tools: ["memory_get", "memory_set"] }); - expect(result).toContain("## Memory"); - expect(result).toContain("search memory first"); - }); - it("full mode includes sub-agents section when sessions_spawn present", () => { const result = buildSystemPrompt({ mode: "full", tools: ["sessions_spawn"] }); expect(result).toContain("## Sub-Agents"); diff --git a/src/agent/system-prompt/sections.test.ts b/src/agent/system-prompt/sections.test.ts index cfd25f6f..7d09b0ce 100644 --- a/src/agent/system-prompt/sections.test.ts +++ b/src/agent/system-prompt/sections.test.ts @@ -163,11 +163,6 @@ describe("buildToolCallStyleSection", () => { }); describe("buildConditionalToolSections", () => { - it("includes memory section when memory tools present", () => { - const result = buildConditionalToolSections(["memory_get", "read"], "full"); - expect(result.join("\n")).toContain("## Memory"); - }); - it("includes sub-agents section when sessions_spawn present in full mode", () => { const result = buildConditionalToolSections(["sessions_spawn"], "full"); expect(result.join("\n")).toContain("## Sub-Agents"); @@ -189,7 +184,7 @@ describe("buildConditionalToolSections", () => { }); it("returns empty in none mode", () => { - expect(buildConditionalToolSections(["memory_get"], "none")).toEqual([]); + expect(buildConditionalToolSections(["read"], "none")).toEqual([]); }); }); diff --git a/src/agent/system-prompt/sections.ts b/src/agent/system-prompt/sections.ts index a0126d56..03008c3a 100644 --- a/src/agent/system-prompt/sections.ts +++ b/src/agent/system-prompt/sections.ts @@ -20,10 +20,6 @@ const CORE_TOOL_SUMMARIES: Record = { process: "Manage background exec sessions", web_search: "Search the web", web_fetch: "Fetch and extract readable content from a URL", - memory_get: "Read from agent memory", - memory_set: "Write to agent memory", - memory_list: "List memory entries", - memory_delete: "Delete memory entries", sessions_spawn: "Spawn a sub-agent session", }; @@ -37,10 +33,6 @@ const TOOL_ORDER = [ "process", "web_search", "web_fetch", - "memory_get", - "memory_set", - "memory_list", - "memory_delete", "sessions_spawn", ]; @@ -216,21 +208,6 @@ export function buildConditionalToolSections( const toolSet = new Set(tools.map((t) => t.toLowerCase())); const lines: string[] = []; - // Memory tools - const hasMemory = - toolSet.has("memory_get") || - toolSet.has("memory_set") || - toolSet.has("memory_list") || - toolSet.has("memory_delete"); - if (hasMemory) { - lines.push( - "## Memory", - "Before answering anything about prior work, decisions, dates, people, preferences, or todos: search memory first, then pull only the needed entries.", - "Update memory when the user shares important information, decisions, or preferences.", - "", - ); - } - // Subagent tools (full mode only — minimal agents cannot spawn) if (mode === "full" && toolSet.has("sessions_spawn")) { lines.push( diff --git a/src/agent/tools.ts b/src/agent/tools.ts index ea9d16de..caa1e5fb 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -6,7 +6,6 @@ import { createExecTool } from "./tools/exec.js"; import { createProcessTool } from "./tools/process.js"; import { createGlobTool } from "./tools/glob.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js"; -import { createMemoryTools } from "./tools/memory/index.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn.js"; import { filterTools } from "./tools/policy.js"; import { isMulticaError, isRetryableError } from "../shared/errors.js"; @@ -17,10 +16,6 @@ export { resolveModel } from "./providers/index.js"; /** Options for creating tools */ export interface CreateToolsOptions { cwd: string; - /** Profile ID for memory tools (optional) */ - profileId?: string | undefined; - /** Base directory for profiles (optional) */ - profileBaseDir?: string | undefined; /** Whether this agent is a subagent (passed to sessions_spawn tool) */ isSubagent?: boolean | undefined; /** Session ID of the agent (passed to sessions_spawn tool) */ @@ -94,7 +89,7 @@ function wrapTool( export function createAllTools(options: CreateToolsOptions | string): AgentTool[] { // Support legacy string argument for backwards compatibility const opts: CreateToolsOptions = typeof options === "string" ? { cwd: options } : options; - const { cwd, profileId, profileBaseDir, isSubagent, sessionId } = opts; + const { cwd, isSubagent, sessionId } = opts; const baseTools = createCodingTools(cwd).filter( (tool) => tool.name !== "bash", @@ -115,15 +110,6 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< webSearchTool as AgentTool, ]; - // Add memory tools if profileId is provided - if (profileId) { - const memoryTools = createMemoryTools({ - profileId, - baseDir: profileBaseDir, - }); - tools.push(...memoryTools); - } - // Add sessions_spawn tool (will be filtered by policy for subagents) const sessionsSpawnTool = createSessionsSpawnTool({ isSubagent: isSubagent ?? false, @@ -146,11 +132,9 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< export function resolveTools(options: AgentOptions): AgentTool[] { const cwd = options.cwd ?? process.cwd(); - // Create all tools (including memory tools if profileId is provided) + // Create all tools const allTools = createAllTools({ cwd, - profileId: options.profileId, - profileBaseDir: options.profileBaseDir, isSubagent: options.isSubagent, sessionId: options.sessionId, }); @@ -167,20 +151,8 @@ export function resolveTools(options: AgentOptions): AgentTool[] { /** * Get all available tool names (for debugging/listing). - * Note: Memory tools require profileId, so they are not included by default. */ export function getAllToolNames(cwd?: string): string[] { const tools = createAllTools({ cwd: cwd ?? process.cwd() }); return tools.map((t) => t.name); } - -/** - * Get all available tool names including memory tools (for debugging/listing). - */ -export function getAllToolNamesWithMemory(cwd?: string, profileId?: string): string[] { - const tools = createAllTools({ - cwd: cwd ?? process.cwd(), - profileId: profileId ?? "test-profile", - }); - return tools.map((t) => t.name); -} diff --git a/src/agent/tools/README.md b/src/agent/tools/README.md index db5b5266..ddbd4c08 100644 --- a/src/agent/tools/README.md +++ b/src/agent/tools/README.md @@ -49,34 +49,31 @@ The tools system provides LLM agents with capabilities to interact with the exte ## Available Tools -| Tool | Name | Description | -| ------------- | --------------- | --------------------------------------- | -| Read | `read` | Read file contents | -| Write | `write` | Write content to files | -| Edit | `edit` | Edit existing files | -| Glob | `glob` | Find files by pattern | -| Exec | `exec` | Execute shell commands | -| Process | `process` | Manage long-running processes | -| Web Fetch | `web_fetch` | Fetch and extract content from URLs | -| Web Search | `web_search` | Search the web (requires API key) | -| Memory Get | `memory_get` | Retrieve a value from persistent memory | -| Memory Set | `memory_set` | Store a value in persistent memory | -| Memory Delete | `memory_delete` | Delete a value from persistent memory | -| Memory List | `memory_list` | List all keys in persistent memory | +| Tool | Name | Description | +| -------------- | ---------------- | ----------------------------------- | +| Read | `read` | Read file contents | +| Write | `write` | Write content to files | +| Edit | `edit` | Edit existing files | +| Glob | `glob` | Find files by pattern | +| Exec | `exec` | Execute shell commands | +| Process | `process` | Manage long-running processes | +| Web Fetch | `web_fetch` | Fetch and extract content from URLs | +| Web Search | `web_search` | Search the web (requires API key) | +| Sessions Spawn | `sessions_spawn` | Spawn a sub-agent session | -> **Note**: Memory tools require a `profileId` to be specified. They store data in the profile's memory directory. +> **Note**: Agents use file-based memory (`memory.md`, `memory/*.md`) via `read` and `edit` tools instead of dedicated memory tools. ## Tool Groups Groups provide shortcuts for allowing/denying multiple tools at once: -| Group | Tools | -| --------------- | -------------------------------------------------- | -| `group:fs` | read, write, edit, glob | -| `group:runtime` | exec, process | -| `group:web` | web_search, web_fetch | -| `group:memory` | memory_get, memory_set, memory_delete, memory_list | -| `group:core` | All of the above (excluding memory) | +| Group | Tools | +| ---------------- | ------------------------------------ | +| `group:fs` | read, write, edit, glob | +| `group:runtime` | exec, process | +| `group:web` | web_search, web_fetch | +| `group:subagent` | sessions_spawn | +| `group:core` | All fs, runtime, and web tools | ## Usage diff --git a/src/agent/tools/README.zh-CN.md b/src/agent/tools/README.zh-CN.md index 80d84815..cb759660 100644 --- a/src/agent/tools/README.zh-CN.md +++ b/src/agent/tools/README.zh-CN.md @@ -49,34 +49,31 @@ ## 可用工具 -| 工具 | 名称 | 描述 | -| ------------- | --------------- | ------------------------ | -| Read | `read` | 读取文件内容 | -| Write | `write` | 写入文件内容 | -| Edit | `edit` | 编辑现有文件 | -| Glob | `glob` | 按模式查找文件 | -| Exec | `exec` | 执行 Shell 命令 | -| Process | `process` | 管理长时间运行的进程 | -| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 | -| Web Search | `web_search` | 搜索网络(需要 API Key) | -| Memory Get | `memory_get` | 从持久化内存中获取值 | -| Memory Set | `memory_set` | 向持久化内存中存储值 | -| Memory Delete | `memory_delete` | 从持久化内存中删除值 | -| Memory List | `memory_list` | 列出持久化内存中的所有键 | +| 工具 | 名称 | 描述 | +| -------------- | ---------------- | ------------------------ | +| Read | `read` | 读取文件内容 | +| Write | `write` | 写入文件内容 | +| Edit | `edit` | 编辑现有文件 | +| Glob | `glob` | 按模式查找文件 | +| Exec | `exec` | 执行 Shell 命令 | +| Process | `process` | 管理长时间运行的进程 | +| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 | +| Web Search | `web_search` | 搜索网络(需要 API Key) | +| Sessions Spawn | `sessions_spawn` | 创建子 Agent 会话 | -> **注意**: Memory 工具需要指定 `profileId`。数据存储在 Profile 的 memory 目录中。 +> **注意**: Agent 使用基于文件的 memory(`memory.md`、`memory/*.md`),通过 `read` 和 `edit` 工具操作,而非专门的 memory 工具。 ## 工具组 工具组提供了一次性允许/禁止多个工具的快捷方式: -| 组 | 工具 | -| --------------- | -------------------------------------------------- | -| `group:fs` | read, write, edit, glob | -| `group:runtime` | exec, process | -| `group:web` | web_search, web_fetch | -| `group:memory` | memory_get, memory_set, memory_delete, memory_list | -| `group:core` | 以上所有(不包括 memory) | +| 组 | 工具 | +| ---------------- | ------------------------------ | +| `group:fs` | read, write, edit, glob | +| `group:runtime` | exec, process | +| `group:web` | web_search, web_fetch | +| `group:subagent` | sessions_spawn | +| `group:core` | 所有 fs、runtime 和 web 工具 | ## 使用方法 diff --git a/src/agent/tools/groups.ts b/src/agent/tools/groups.ts index dde6b5a0..56d793bb 100644 --- a/src/agent/tools/groups.ts +++ b/src/agent/tools/groups.ts @@ -30,9 +30,6 @@ export const TOOL_GROUPS: Record = { // Web tools "group:web": ["web_search", "web_fetch"], - // Memory tools (requires profileId) - "group:memory": ["memory_get", "memory_set", "memory_delete", "memory_list"], - // Subagent tools "group:subagent": ["sessions_spawn"], diff --git a/src/agent/tools/memory/index.ts b/src/agent/tools/memory/index.ts deleted file mode 100644 index f2d14e6e..00000000 --- a/src/agent/tools/memory/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Memory Tools Module - */ - -export { createMemoryTools } from "./memory-tools.js"; -export type { MemoryEntry, MemoryStorageOptions, MemoryListResult } from "./types.js"; diff --git a/src/agent/tools/memory/memory-tools.ts b/src/agent/tools/memory/memory-tools.ts deleted file mode 100644 index 19fe5804..00000000 --- a/src/agent/tools/memory/memory-tools.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Memory Tools - * - * Provides persistent key-value storage for agents. - */ - -import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { memoryDelete, memoryGet, memoryList, memorySet, validateKey } from "./storage.js"; -import type { MemoryStorageOptions } from "./types.js"; - -// ============================================================================ -// Schemas -// ============================================================================ - -const MemoryGetSchema = Type.Object({ - key: Type.String({ description: "The key to retrieve" }), -}); - -const MemorySetSchema = Type.Object({ - key: Type.String({ description: "The key to set (alphanumeric, underscore, dot, hyphen)" }), - value: Type.Unknown({ description: "The value to store (will be JSON serialized)" }), - description: Type.Optional( - Type.String({ description: "Optional description of this memory entry" }), - ), -}); - -const MemoryDeleteSchema = Type.Object({ - key: Type.String({ description: "The key to delete" }), -}); - -const MemoryListSchema = Type.Object({ - prefix: Type.Optional(Type.String({ description: "Filter keys by prefix" })), - limit: Type.Optional(Type.Number({ description: "Maximum number of keys to return (default 100)" })), -}); - -// ============================================================================ -// Helper -// ============================================================================ - -function jsonResult(data: T): { - content: Array<{ type: "text"; text: string }>; - details: T; -} { - return { - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], - details: data, - }; -} - -// ============================================================================ -// Tools -// ============================================================================ - -export function createMemoryGetTool( - options: MemoryStorageOptions, -): AgentTool { - return { - name: "memory_get", - label: "Memory Get", - description: "Retrieve a value from persistent memory by key.", - parameters: MemoryGetSchema, - execute: async (_toolCallId, params) => { - const key = typeof params.key === "string" ? params.key.trim() : ""; - - const validation = validateKey(key); - if (!validation.valid) { - return jsonResult({ found: false, error: validation.error }); - } - - const result = memoryGet(key, options); - if (!result.found) { - return jsonResult({ found: false, key }); - } - - return jsonResult({ - found: true, - key, - value: result.entry.value, - description: result.entry.description, - updatedAt: result.entry.updatedAt, - }); - }, - }; -} - -export function createMemorySetTool( - options: MemoryStorageOptions, -): AgentTool { - return { - name: "memory_set", - label: "Memory Set", - description: - "Store a value in persistent memory. The value will be JSON serialized. " + - "Keys can contain letters, numbers, underscores, dots, and hyphens.", - parameters: MemorySetSchema, - execute: async (_toolCallId, params) => { - const key = typeof params.key === "string" ? params.key.trim() : ""; - const value = params.value; - const description = typeof params.description === "string" ? params.description : undefined; - - const result = memorySet(key, value, description, options); - if (!result.success) { - return jsonResult({ success: false, error: result.error }); - } - - return jsonResult({ success: true, key }); - }, - }; -} - -export function createMemoryDeleteTool( - options: MemoryStorageOptions, -): AgentTool { - return { - name: "memory_delete", - label: "Memory Delete", - description: "Delete a value from persistent memory by key.", - parameters: MemoryDeleteSchema, - execute: async (_toolCallId, params) => { - const key = typeof params.key === "string" ? params.key.trim() : ""; - - const validation = validateKey(key); - if (!validation.valid) { - return jsonResult({ success: false, error: validation.error }); - } - - const result = memoryDelete(key, options); - if (!result.success) { - return jsonResult({ success: false, error: result.error }); - } - - return jsonResult({ success: true, key, existed: result.existed }); - }, - }; -} - -export function createMemoryListTool( - options: MemoryStorageOptions, -): AgentTool { - return { - name: "memory_list", - label: "Memory List", - description: - "List all keys in persistent memory, sorted by most recently updated. " + - "Optionally filter by prefix.", - parameters: MemoryListSchema, - execute: async (_toolCallId, params) => { - const prefix = typeof params.prefix === "string" ? params.prefix : undefined; - const limit = typeof params.limit === "number" ? params.limit : undefined; - - const result = memoryList(prefix, limit, options); - - return jsonResult({ - keys: result.keys, - total: result.total, - truncated: result.truncated, - }); - }, - }; -} - -/** - * Create all memory tools for a profile - */ -export function createMemoryTools( - options: MemoryStorageOptions, -): Array> { - return [ - createMemoryGetTool(options), - createMemorySetTool(options), - createMemoryDeleteTool(options), - createMemoryListTool(options), - ]; -} diff --git a/src/agent/tools/memory/storage.test.ts b/src/agent/tools/memory/storage.test.ts deleted file mode 100644 index c67592aa..00000000 --- a/src/agent/tools/memory/storage.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { - validateKey, - memoryGet, - memorySet, - memoryDelete, - memoryList, - getMemoryDir, -} from "./storage.js"; -import type { MemoryStorageOptions } from "./types.js"; - -describe("memory storage", () => { - const testBaseDir = join(tmpdir(), `multica-memory-test-${Date.now()}`); - const profileId = "test-profile"; - - const options: MemoryStorageOptions = { - profileId, - baseDir: testBaseDir, - }; - - beforeEach(() => { - if (existsSync(testBaseDir)) { - rmSync(testBaseDir, { recursive: true }); - } - mkdirSync(testBaseDir, { recursive: true }); - }); - - afterEach(() => { - if (existsSync(testBaseDir)) { - rmSync(testBaseDir, { recursive: true }); - } - }); - - describe("validateKey", () => { - it("should accept valid alphanumeric keys", () => { - expect(validateKey("mykey")).toEqual({ valid: true }); - expect(validateKey("my_key")).toEqual({ valid: true }); - expect(validateKey("my-key")).toEqual({ valid: true }); - expect(validateKey("my.key")).toEqual({ valid: true }); - expect(validateKey("MyKey123")).toEqual({ valid: true }); - }); - - it("should reject empty keys", () => { - expect(validateKey("")).toMatchObject({ valid: false, error: "Key is required" }); - expect(validateKey(" ")).toMatchObject({ valid: false, error: "Key cannot be empty" }); - }); - - it("should reject keys with invalid characters", () => { - const result = validateKey("my key"); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.error).toContain("can only contain"); - } - }); - - it("should reject keys that are too long", () => { - const longKey = "a".repeat(129); - const result = validateKey(longKey); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.error).toContain("exceeds maximum length"); - } - }); - }); - - describe("memorySet and memoryGet", () => { - it("should set and get a string value", () => { - const result = memorySet("test-key", "test-value", undefined, options); - expect(result).toEqual({ success: true }); - - const getResult = memoryGet("test-key", options); - expect(getResult.found).toBe(true); - if (getResult.found) { - expect(getResult.entry.value).toBe("test-value"); - } - }); - - it("should set and get a complex object", () => { - const value = { name: "test", count: 42, nested: { a: 1 } }; - memorySet("complex-key", value, "A complex object", options); - - const getResult = memoryGet("complex-key", options); - expect(getResult.found).toBe(true); - if (getResult.found) { - expect(getResult.entry.value).toEqual(value); - expect(getResult.entry.description).toBe("A complex object"); - } - }); - - it("should update existing key and preserve createdAt", async () => { - memorySet("update-key", "initial", undefined, options); - const firstGet = memoryGet("update-key", options); - expect(firstGet.found).toBe(true); - - // Wait a bit to ensure different timestamp - await new Promise((resolve) => setTimeout(resolve, 10)); - - memorySet("update-key", "updated", undefined, options); - const secondGet = memoryGet("update-key", options); - - expect(secondGet.found).toBe(true); - if (firstGet.found && secondGet.found) { - expect(secondGet.entry.value).toBe("updated"); - expect(secondGet.entry.createdAt).toBe(firstGet.entry.createdAt); - expect(secondGet.entry.updatedAt).toBeGreaterThan(firstGet.entry.createdAt); - } - }); - - it("should return not found for non-existent key", () => { - const result = memoryGet("non-existent", options); - expect(result.found).toBe(false); - }); - - it("should handle keys with dots", () => { - memorySet("user.settings.theme", "dark", undefined, options); - - const result = memoryGet("user.settings.theme", options); - expect(result.found).toBe(true); - if (result.found) { - expect(result.entry.value).toBe("dark"); - } - }); - - it("should reject value that is too large", () => { - const largeValue = "x".repeat(1024 * 1024 + 1); - const result = memorySet("large-key", largeValue, undefined, options); - expect(result).toMatchObject({ success: false }); - if (!result.success) { - expect(result.error).toContain("exceeds maximum size"); - } - }); - }); - - describe("memoryDelete", () => { - it("should delete existing key", () => { - memorySet("delete-me", "value", undefined, options); - expect(memoryGet("delete-me", options).found).toBe(true); - - const result = memoryDelete("delete-me", options); - expect(result).toEqual({ success: true, existed: true }); - - expect(memoryGet("delete-me", options).found).toBe(false); - }); - - it("should handle deleting non-existent key", () => { - const result = memoryDelete("non-existent", options); - expect(result).toEqual({ success: true, existed: false }); - }); - - it("should reject invalid key", () => { - const result = memoryDelete("invalid key", options); - expect(result.success).toBe(false); - }); - }); - - describe("memoryList", () => { - beforeEach(() => { - // Create some test keys - memorySet("project.config", { name: "test" }, "Project config", options); - memorySet("project.settings", { theme: "dark" }, "Settings", options); - memorySet("user.name", "Alice", "User name", options); - }); - - it("should list all keys", () => { - const result = memoryList(undefined, undefined, options); - - expect(result.total).toBe(3); - expect(result.truncated).toBe(false); - expect(result.keys.map((k) => k.key)).toContain("project.config"); - expect(result.keys.map((k) => k.key)).toContain("project.settings"); - expect(result.keys.map((k) => k.key)).toContain("user.name"); - }); - - it("should filter by prefix", () => { - const result = memoryList("project", undefined, options); - - expect(result.total).toBe(2); - expect(result.keys.map((k) => k.key)).toContain("project.config"); - expect(result.keys.map((k) => k.key)).toContain("project.settings"); - expect(result.keys.map((k) => k.key)).not.toContain("user.name"); - }); - - it("should respect limit", () => { - const result = memoryList(undefined, 2, options); - - expect(result.keys.length).toBe(2); - expect(result.total).toBe(3); - expect(result.truncated).toBe(true); - }); - - it("should sort by updatedAt descending", async () => { - // Wait and update one key - await new Promise((resolve) => setTimeout(resolve, 10)); - memorySet("project.config", { name: "updated" }, "Updated config", options); - - const result = memoryList(undefined, undefined, options); - - // project.config should be first as it was updated most recently - expect(result.keys[0]?.key).toBe("project.config"); - }); - - it("should return empty array for non-existent directory", () => { - const emptyOptions: MemoryStorageOptions = { - profileId: "non-existent-profile", - baseDir: testBaseDir, - }; - - const result = memoryList(undefined, undefined, emptyOptions); - expect(result.keys).toEqual([]); - expect(result.total).toBe(0); - }); - }); - - describe("getMemoryDir", () => { - it("should return correct memory directory path", () => { - const dir = getMemoryDir(options); - expect(dir).toContain(profileId); - expect(dir).toContain("memory"); - }); - }); -}); diff --git a/src/agent/tools/memory/storage.ts b/src/agent/tools/memory/storage.ts deleted file mode 100644 index e7c29758..00000000 --- a/src/agent/tools/memory/storage.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Memory Storage Layer - * - * Handles file-based storage for agent memory in the profile directory. - */ - -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { getProfileDir } from "../../profile/storage.js"; -import { - DEFAULT_LIST_LIMIT, - KEY_PATTERN, - MAX_KEY_LENGTH, - MAX_LIST_LIMIT, - MAX_VALUE_SIZE, - type MemoryEntry, - type MemoryListResult, - type MemoryStorageOptions, -} from "./types.js"; - -/** - * Validate a memory key - */ -export function validateKey(key: string): { valid: true } | { valid: false; error: string } { - if (!key || typeof key !== "string") { - return { valid: false, error: "Key is required" }; - } - - const trimmed = key.trim(); - if (trimmed.length === 0) { - return { valid: false, error: "Key cannot be empty" }; - } - - if (trimmed.length > MAX_KEY_LENGTH) { - return { valid: false, error: `Key exceeds maximum length of ${MAX_KEY_LENGTH}` }; - } - - if (!KEY_PATTERN.test(trimmed)) { - return { - valid: false, - error: "Key can only contain letters, numbers, underscores, dots, and hyphens", - }; - } - - return { valid: true }; -} - -/** - * Get the memory directory for a profile - */ -export function getMemoryDir(options: MemoryStorageOptions): string { - const profileDir = getProfileDir(options.profileId, { baseDir: options.baseDir }); - return join(profileDir, "memory"); -} - -/** - * Ensure the memory directory exists - */ -export function ensureMemoryDir(options: MemoryStorageOptions): string { - const memoryDir = getMemoryDir(options); - if (!existsSync(memoryDir)) { - mkdirSync(memoryDir, { recursive: true }); - } - return memoryDir; -} - -/** - * Get the file path for a memory key - */ -function getKeyFilePath(key: string, options: MemoryStorageOptions): string { - const memoryDir = getMemoryDir(options); - // Sanitize key for filename (replace dots with double underscore to avoid extension issues) - const safeKey = key.replace(/\./g, "__DOT__"); - return join(memoryDir, `${safeKey}.json`); -} - -/** - * Decode a sanitized filename back to the original key - */ -function decodeKeyFromFilename(filename: string): string { - // Remove .json extension and decode - const base = filename.replace(/\.json$/, ""); - return base.replace(/__DOT__/g, "."); -} - -/** - * Get a memory value by key - */ -export function memoryGet( - key: string, - options: MemoryStorageOptions, -): { found: true; entry: MemoryEntry } | { found: false } { - const validation = validateKey(key); - if (!validation.valid) { - return { found: false }; - } - - const filePath = getKeyFilePath(key.trim(), options); - if (!existsSync(filePath)) { - return { found: false }; - } - - try { - const content = readFileSync(filePath, "utf-8"); - const entry = JSON.parse(content) as MemoryEntry; - return { found: true, entry }; - } catch { - return { found: false }; - } -} - -/** - * Set a memory value - */ -export function memorySet( - key: string, - value: unknown, - description: string | undefined, - options: MemoryStorageOptions, -): { success: true } | { success: false; error: string } { - const validation = validateKey(key); - if (validation.valid === false) { - return { success: false, error: validation.error }; - } - - // Check value size - const serialized = JSON.stringify(value); - if (serialized.length > MAX_VALUE_SIZE) { - return { success: false, error: `Value exceeds maximum size of ${MAX_VALUE_SIZE} bytes` }; - } - - const trimmedKey = key.trim(); - ensureMemoryDir(options); - - const now = Date.now(); - const existing = memoryGet(trimmedKey, options); - - const trimmedDescription = description?.trim(); - const entry: MemoryEntry = { - value, - ...(trimmedDescription ? { description: trimmedDescription } : {}), - createdAt: existing.found ? existing.entry.createdAt : now, - updatedAt: now, - }; - - const filePath = getKeyFilePath(trimmedKey, options); - - try { - writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8"); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: `Failed to write memory: ${message}` }; - } -} - -/** - * Delete a memory key - */ -export function memoryDelete( - key: string, - options: MemoryStorageOptions, -): { success: true; existed: boolean } | { success: false; error: string } { - const validation = validateKey(key); - if (validation.valid === false) { - return { success: false, error: validation.error }; - } - - const filePath = getKeyFilePath(key.trim(), options); - const existed = existsSync(filePath); - - if (existed) { - try { - rmSync(filePath); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: `Failed to delete memory: ${message}` }; - } - } - - return { success: true, existed }; -} - -/** - * List memory keys - */ -export function memoryList( - prefix: string | undefined, - limit: number | undefined, - options: MemoryStorageOptions, -): MemoryListResult { - const memoryDir = getMemoryDir(options); - - if (!existsSync(memoryDir)) { - return { keys: [], total: 0, truncated: false }; - } - - const effectiveLimit = Math.min( - Math.max(1, limit ?? DEFAULT_LIST_LIMIT), - MAX_LIST_LIMIT, - ); - - try { - const files = readdirSync(memoryDir).filter((f) => f.endsWith(".json")); - const entries: Array<{ key: string; description?: string; updatedAt: number }> = []; - - for (const file of files) { - const key = decodeKeyFromFilename(file); - - // Apply prefix filter - if (prefix && !key.startsWith(prefix)) { - continue; - } - - const filePath = join(memoryDir, file); - try { - const content = readFileSync(filePath, "utf-8"); - const entry = JSON.parse(content) as MemoryEntry; - entries.push({ - key, - ...(entry.description ? { description: entry.description } : {}), - updatedAt: entry.updatedAt, - }); - } catch { - // Skip invalid files - } - } - - // Sort by updatedAt descending (most recent first) - entries.sort((a, b) => b.updatedAt - a.updatedAt); - - const total = entries.length; - const truncated = total > effectiveLimit; - const keys = entries.slice(0, effectiveLimit); - - return { keys, total, truncated }; - } catch { - return { keys: [], total: 0, truncated: false }; - } -} diff --git a/src/agent/tools/memory/types.ts b/src/agent/tools/memory/types.ts deleted file mode 100644 index 58bc15bf..00000000 --- a/src/agent/tools/memory/types.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Memory Tool Type Definitions - */ - -/** Memory entry stored in JSON file */ -export interface MemoryEntry { - /** The stored value */ - value: unknown; - /** Optional description of this memory entry */ - description?: string; - /** Timestamp when created */ - createdAt: number; - /** Timestamp when last updated */ - updatedAt: number; -} - -/** Memory index structure */ -export interface MemoryIndex { - /** Version for future migrations */ - version: 1; - /** Map of key to metadata */ - keys: Record; -} - -/** Metadata for each key in the index */ -export interface MemoryKeyMeta { - /** Optional description */ - description?: string; - /** Created timestamp */ - createdAt: number; - /** Updated timestamp */ - updatedAt: number; -} - -/** Options for memory storage */ -export interface MemoryStorageOptions { - /** Profile ID (required for storage path) */ - profileId: string; - /** Base directory for profiles */ - baseDir?: string | undefined; -} - -/** Result from memory_list */ -export interface MemoryListResult { - keys: Array<{ - key: string; - description?: string; - updatedAt: number; - }>; - total: number; - truncated: boolean; -} - -/** Valid key pattern: alphanumeric, underscore, dot, hyphen */ -export const KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/; - -/** Maximum key length */ -export const MAX_KEY_LENGTH = 128; - -/** Maximum value size in bytes (1MB) */ -export const MAX_VALUE_SIZE = 1024 * 1024; - -/** Default list limit */ -export const DEFAULT_LIST_LIMIT = 100; - -/** Maximum list limit */ -export const MAX_LIST_LIMIT = 1000;