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:
parent
6e8f0a3c41
commit
1e1fa410c3
13 changed files with 42 additions and 827 deletions
|
|
@ -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'],
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 工具 |
|
||||
|
||||
## 使用方法
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* Memory Tools Module
|
||||
*/
|
||||
|
||||
export { createMemoryTools } from "./memory-tools.js";
|
||||
export type { MemoryEntry, MemoryStorageOptions, MemoryListResult } from "./types.js";
|
||||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue