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 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-05 14:41:08 +08:00
parent 6e8f0a3c41
commit 1e1fa410c3
13 changed files with 42 additions and 827 deletions

View file

@ -12,7 +12,6 @@ const TOOL_GROUPS: Record<string, string[]> = {
'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'],
]

View file

@ -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");

View file

@ -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([]);
});
});

View file

@ -20,10 +20,6 @@ const CORE_TOOL_SUMMARIES: Record<string, string> = {
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(

View file

@ -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<TParams extends TSchema, TResult>(
export function createAllTools(options: CreateToolsOptions | string): AgentTool<any>[] {
// 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<any>,
];
// 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<any>[] {
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<any>[] {
/**
* 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);
}

View file

@ -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

View file

@ -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 工具 |
## 使用方法

View file

@ -30,9 +30,6 @@ export const TOOL_GROUPS: Record<string, string[]> = {
// 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"],

View file

@ -1,6 +0,0 @@
/**
* Memory Tools Module
*/
export { createMemoryTools } from "./memory-tools.js";
export type { MemoryEntry, MemoryStorageOptions, MemoryListResult } from "./types.js";

View file

@ -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<T>(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<typeof MemoryGetSchema> {
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<typeof MemorySetSchema> {
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<typeof MemoryDeleteSchema> {
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<typeof MemoryListSchema> {
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<AgentTool<any>> {
return [
createMemoryGetTool(options),
createMemorySetTool(options),
createMemoryDeleteTool(options),
createMemoryListTool(options),
];
}

View file

@ -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");
});
});
});

View file

@ -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 };
}
}

View file

@ -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<string, MemoryKeyMeta>;
}
/** 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;