refactor(agent): remove legacy memory subsystem

This commit is contained in:
Jiayuan Zhang 2026-02-17 15:33:39 +08:00
parent 700e64342c
commit 0f5bd5fff1
26 changed files with 15 additions and 599 deletions

View file

@ -48,7 +48,6 @@ ${cyan("Profile Structure:")}
- soul.md Agent identity, personality and behavior
- user.md Information about the user
- workspace.md Workspace rules and conventions
- memory.md Persistent knowledge
${cyan("Examples:")}
${dim("# Create a new profile")}
@ -94,7 +93,6 @@ function cmdNew(profileId: string | undefined) {
console.log(" - soul.md (identity, personality and behavior)");
console.log(" - user.md (information about the user)");
console.log(" - workspace.md (workspace rules and conventions)");
console.log(" - memory.md (persistent knowledge)");
console.log("");
console.log("Run interactive setup to personalize your agent:");
console.log(` multica profile setup ${profileId}`);
@ -165,11 +163,6 @@ function cmdShow(profileId: string | undefined) {
console.log("");
}
if (profile.memory) {
console.log(`${green("=== memory.md ===")}`);
console.log(profile.memory.trim());
console.log("");
}
}
async function cmdEdit(profileId: string | undefined) {

View file

@ -31,7 +31,7 @@ function printUsage() {
console.log(`${cyan("Usage:")} pnpm agent:interactive [options]`);
console.log("");
console.log(`${cyan("Options:")}`);
console.log(` ${yellow("--profile")} ID Load agent profile (identity, soul, tools, memory)`);
console.log(` ${yellow("--profile")} ID Load agent profile (identity, soul, tools)`);
console.log(` ${yellow("--provider")} NAME LLM provider (e.g., openai, anthropic, kimi)`);
console.log(` ${yellow("--model")} NAME Model name`);
console.log(` ${yellow("--system")} TEXT System prompt (ignored if --profile is set)`);

View file

@ -25,7 +25,7 @@ function printUsage() {
console.log(" echo \"your prompt\" | pnpm agent:cli");
console.log("");
console.log("Options:");
console.log(" --profile ID Load agent profile (identity, soul, tools, memory)");
console.log(" --profile ID Load agent profile (identity, soul, tools)");
console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)");
console.log(" --model NAME Model name");
console.log(" --api-key KEY API key (overrides environment variable)");

View file

@ -68,7 +68,6 @@ function cmdNew(profileId: string | undefined) {
console.log(" - soul.md (identity, personality and behavior)");
console.log(" - user.md (information about the user)");
console.log(" - workspace.md (workspace rules and conventions)");
console.log(" - memory.md (persistent knowledge)");
console.log("");
console.log("Edit these files to customize your agent, then run:");
console.log(` pnpm agent:cli --profile ${profileId} "Hello"`);
@ -137,11 +136,6 @@ function cmdShow(profileId: string | undefined) {
console.log("");
}
if (profile.memory) {
console.log("=== memory.md ===");
console.log(profile.memory.trim());
console.log("");
}
}
async function cmdEdit(profileId: string | undefined) {

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_search'],
'group:subagent': ['delegate'],
'group:cron': ['cron'],
}
@ -22,7 +21,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'],
...TOOL_GROUPS['group:cron'],
]

View file

@ -11,7 +11,6 @@ import {
FolderOpen,
Code,
Globe,
Brain,
ChevronRight,
Loader2,
Clock,
@ -25,7 +24,6 @@ const GROUP_NAMES: Record<string, string> = {
fs: 'File System',
runtime: 'Runtime',
web: 'Web',
memory: 'Memory',
subagent: 'Subagent',
cron: 'Cron',
other: 'Other',
@ -36,7 +34,6 @@ const GROUP_DESCRIPTIONS: Record<string, string> = {
fs: 'Read, write, and manage files',
runtime: 'Execute code and commands',
web: 'Fetch and interact with web content',
memory: 'Store and recall information',
subagent: 'Delegate tasks to sub-agents',
cron: 'Schedule recurring tasks',
other: 'Miscellaneous tools',
@ -47,7 +44,6 @@ const GROUP_ICONS: Record<string, typeof FolderOpen> = {
fs: FolderOpen,
runtime: Code,
web: Globe,
memory: Brain,
subagent: Users,
cron: Clock,
other: Code,

View file

@ -22,11 +22,6 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
process: 'Manage background processes',
web_fetch: 'Fetch content from URLs',
web_search: 'Search the web via Devv Search',
memory_get: 'Get stored memory value',
memory_set: 'Store a memory value',
memory_delete: 'Delete a memory value',
memory_list: 'List all memory keys',
memory_search: 'Search memory files for keywords',
cron: 'Create and manage scheduled tasks',
}

View file

@ -41,7 +41,7 @@ multica skills remove <name>
- base coding tools (`read/write/edit/...`)
- extended tools (`exec`, `process`, `glob`, `web_fetch`, `web_search`, `data`, `cron`, `delegate`)
- conditional tools (`memory_search`, `send_file`)
- conditional tools (`send_file`)
Tool errors are wrapped into structured tool results instead of crashing runs.
@ -52,7 +52,6 @@ Supported group aliases:
- `group:fs` -> `read, write, edit, glob`
- `group:runtime` -> `exec, process`
- `group:web` -> `web_search, web_fetch`
- `group:memory` -> `memory_search`
- `group:subagent` -> `delegate`
- `group:cron` -> `cron`
- `group:data` -> `data`

View file

@ -49,7 +49,6 @@ export function createAgentProfile(
profile.soul = DEFAULT_TEMPLATES.soul;
profile.user = DEFAULT_TEMPLATES.user;
profile.workspace = DEFAULT_TEMPLATES.workspace;
profile.memory = DEFAULT_TEMPLATES.memory;
profile.heartbeat = DEFAULT_TEMPLATES.heartbeat;
profile.config = { name: "Multica" };
@ -151,7 +150,6 @@ export class ProfileManager {
soul: profile.soul,
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},

View file

@ -153,7 +153,6 @@ describe("storage", () => {
writeFileSync(join(dir, "soul.md"), "Soul content");
writeFileSync(join(dir, "user.md"), "User content");
writeFileSync(join(dir, "workspace.md"), "Workspace content");
writeFileSync(join(dir, "memory.md"), "Memory content");
const profile = loadProfile(profileId, { baseDir: testBaseDir });
@ -161,7 +160,6 @@ describe("storage", () => {
expect(profile.soul).toBe("Soul content");
expect(profile.user).toBe("User content");
expect(profile.workspace).toBe("Workspace content");
expect(profile.memory).toBe("Memory content");
});
it("should return undefined for missing files", () => {
@ -177,7 +175,6 @@ describe("storage", () => {
expect(profile.soul).toBe("Soul only");
expect(profile.user).toBeUndefined();
expect(profile.workspace).toBeUndefined();
expect(profile.memory).toBeUndefined();
});
it("should handle non-existent profile", () => {
@ -195,7 +192,6 @@ describe("storage", () => {
soul: "Soul data",
user: "User data",
workspace: "Workspace data",
memory: "Memory data",
};
saveProfile(profile, { baseDir: testBaseDir });
@ -204,7 +200,6 @@ describe("storage", () => {
expect(readFileSync(join(dir, "soul.md"), "utf-8")).toBe("Soul data");
expect(readFileSync(join(dir, "user.md"), "utf-8")).toBe("User data");
expect(readFileSync(join(dir, "workspace.md"), "utf-8")).toBe("Workspace data");
expect(readFileSync(join(dir, "memory.md"), "utf-8")).toBe("Memory data");
});
it("should only save defined fields", () => {
@ -213,7 +208,6 @@ describe("storage", () => {
soul: "Soul only",
user: undefined,
workspace: undefined,
memory: undefined,
};
saveProfile(profile, { baseDir: testBaseDir });

View file

@ -94,7 +94,6 @@ export function loadProfile(profileId: string, options?: StorageOptions): AgentP
soul: readProfileFile(profileId, PROFILE_FILES.soul, options),
user: readProfileFile(profileId, PROFILE_FILES.user, options),
workspace: readProfileFile(profileId, PROFILE_FILES.workspace, options),
memory: readProfileFile(profileId, PROFILE_FILES.memory, options),
heartbeat: readProfileFile(profileId, PROFILE_FILES.heartbeat, options),
config: readProfileConfig(profileId, options),
};
@ -102,7 +101,7 @@ export function loadProfile(profileId: string, options?: StorageOptions): AgentP
/** 保存 AgentProfile只写入非空字段 */
export function saveProfile(profile: AgentProfile, options?: StorageOptions): void {
const { id, soul, user, workspace, memory, heartbeat, config } = profile;
const { id, soul, user, workspace, heartbeat, config } = profile;
if (soul !== undefined) {
writeProfileFile(id, PROFILE_FILES.soul, soul, options);
@ -113,9 +112,6 @@ export function saveProfile(profile: AgentProfile, options?: StorageOptions): vo
if (workspace !== undefined) {
writeProfileFile(id, PROFILE_FILES.workspace, workspace, options);
}
if (memory !== undefined) {
writeProfileFile(id, PROFILE_FILES.memory, memory, options);
}
if (heartbeat !== undefined) {
writeProfileFile(id, PROFILE_FILES.heartbeat, heartbeat, options);
}

View file

@ -31,7 +31,7 @@ _You're not a chatbot. You're becoming someone._
## Continuity
Each session, you wake up fresh. These files are your memory. Read them. Update them. They're how you persist.
Each session, you wake up fresh. These files are your continuity. Read them. Update them. They're how you persist.
If you change this file, tell the user it's your soul, and they should know.
@ -71,7 +71,6 @@ Your profile directory contains these files (use \`edit\` or \`write\` to update
| \`soul.md\` | Who you are, your identity and values | Rarely — tell user if you do |
| \`user.md\` | About your human | As you learn about them |
| \`workspace.md\` | This file — your rules | When you discover better conventions |
| \`memory.md\` | Long-term knowledge | Regularly — capture what matters |
| \`heartbeat.md\` | Background check instructions | When heartbeat behavior should change |
## Every Session
@ -80,41 +79,10 @@ Before doing anything else:
1. Read \`soul.md\` — this is who you are
2. Read \`user.md\` — this is who you're helping
3. Check \`memory.md\` for context
3. Read \`workspace.md\` — these are your operating conventions
Don't ask permission. Just do it.
## Memory
You wake up fresh each session. These files are your continuity:
- **Long-term:** \`MEMORY.md\` — your curated memories, lessons learned
- **Daily notes:** \`memory/YYYY-MM-DD.md\` — raw logs of what happened (optional)
- **Heartbeat:** \`heartbeat.md\` — periodic check loop instructions
Capture what matters. Decisions, context, things to remember.
### 📝 Write It Down - No "Mental Notes"!
**CRITICAL**: You CANNOT "remember" things mentally. Your memory resets each session. If you don't write it to a file, it's gone.
**Which file to edit:**
- \`user.md\` — About your human: name, preferences, habits, context, anything personal
- \`memory.md\` — Your learnings: decisions made, lessons learned, important context
- \`workspace.md\` — Your rules: conventions, workflows, how you should operate
- \`soul.md\` — Your identity: only change if user wants to reshape who you are
- \`heartbeat.md\` — Periodic background checks and alert rules
**Rules:**
- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file
- **DO NOT** make "mental notes" they don't exist
- When you learn something about the user (name, preference, habit) IMMEDIATELY update \`user.md\`
- When you learn a lesson, make a decision, or gain context IMMEDIATELY update \`memory.md\`
- When you discover a better workflow or convention update \`workspace.md\`
- When you make a mistake document it so future-you doesn't repeat it
**Text > Brain** 📝
## Safety
- Don't exfiltrate private data. Ever.
@ -140,17 +108,6 @@ Capture what matters. Decisions, context, things to remember.
## Make It Yours
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
`,
memory: `# Memory
_(Persistent knowledge will be stored here. Update this as you learn.)_
## Key Decisions
## Lessons Learned
## Important Context
`,
heartbeat: `# heartbeat.md

View file

@ -10,7 +10,6 @@ export const PROFILE_FILES = {
soul: "soul.md",
user: "user.md",
workspace: "workspace.md",
memory: "memory.md",
heartbeat: "heartbeat.md",
config: "config.json",
} as const;
@ -56,8 +55,6 @@ export interface AgentProfile {
user?: string | undefined;
/** Workspace guidelines - behavior rules and conventions */
workspace?: string | undefined;
/** Persistent memory - long-term knowledge base */
memory?: string | undefined;
/** Periodic heartbeat instructions */
heartbeat?: string | undefined;
/** Profile configuration (from config.json) */

View file

@ -519,7 +519,7 @@ export class Agent {
});
// Load Agent Profile (if profileId is specified)
// Every Agent should have a Profile for memory, tools config, and other settings
// Every Agent should have a Profile for tools config and other settings
if (options.profileId) {
this.profile = new ProfileManager({
profileId: options.profileId,
@ -644,7 +644,6 @@ export class Agent {
// Merge Profile tools config with options.tools (options takes precedence)
const profileToolsConfig = this.profile?.getToolsConfig();
const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools);
const profileDir = this.profile?.getProfileDir();
// Use this.sessionId (which may be auto-generated) instead of options.sessionId
// (which may be undefined). Without this, delegate tool has no session context.
this.toolsOptions = mergedToolsConfig
@ -653,7 +652,6 @@ export class Agent {
sessionId: this.sessionId,
cwd: effectiveCwd,
tools: mergedToolsConfig,
profileDir,
provider: this.resolvedProvider,
runLog: this.runLog,
onExecApprovalNeeded: this.guardedExecApproval,
@ -662,7 +660,6 @@ export class Agent {
...options,
sessionId: this.sessionId,
cwd: effectiveCwd,
profileDir,
provider: this.resolvedProvider,
runLog: this.runLog,
onExecApprovalNeeded: this.guardedExecApproval,
@ -1883,7 +1880,6 @@ export class Agent {
soul: profile.soul,
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},

View file

@ -7,7 +7,6 @@ const PROFILE = {
soul: "# Soul\nYou are a helpful coding assistant.",
user: "# User\nName: Alice",
workspace: "# Workspace\nFollow conventional commits.",
memory: "# Memory\nUser prefers TypeScript.",
config: { name: "TestAgent" },
};
@ -17,12 +16,11 @@ describe("buildSystemPrompt", () => {
// ── Full mode ─────────────────────────────────────────────────────────
it("full mode includes workspace section only (progressive disclosure)", () => {
// Soul, user, memory are read on-demand by the agent
// Soul and user are read on-demand by the agent
const result = buildSystemPrompt({ mode: "full", profile: PROFILE });
expect(result).not.toContain("# Soul");
expect(result).not.toContain("# User");
expect(result).toContain("# Workspace");
expect(result).not.toContain("# Memory");
});
it("full mode includes safety constitution", () => {
@ -82,7 +80,6 @@ describe("buildSystemPrompt", () => {
expect(result).toContain("/home/user/.super-multica/agent-profiles/test");
expect(result).toContain("soul.md");
expect(result).toContain("user.md");
expect(result).toContain("memory.md");
});
it("full mode excludes subagent section", () => {
@ -100,7 +97,6 @@ describe("buildSystemPrompt", () => {
expect(result).not.toContain("# Soul");
expect(result).not.toContain("# User");
expect(result).not.toContain("# Workspace");
expect(result).not.toContain("# Memory");
});
it("minimal mode includes safety constitution", () => {
@ -246,7 +242,7 @@ describe("buildSystemPromptWithReport", () => {
const identity = report.sections.find((s) => s.name === "identity");
expect(identity?.included).toBe(true);
// User and memory are excluded (progressive disclosure)
// User is excluded (progressive disclosure)
const user = report.sections.find((s) => s.name === "user");
expect(user?.included).toBe(false);

View file

@ -15,7 +15,6 @@ import {
buildConditionalToolSections,
buildExtraPromptSection,
buildIdentitySection,
buildMemoryFileSection,
buildProfileDirSection,
buildRuntimeSection,
buildSafetySection,
@ -74,7 +73,6 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir, workspaceDir),
...(workspaceTruncated ? { truncated: true, originalChars: workspaceOriginalChars } : {}),
},
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
{ name: "heartbeat", lines: buildHeartbeatSection(profile, mode) },
{ name: "safety", lines: buildSafetySection(includeSafety) },
{ name: "tooling", lines: buildToolingSummary(tools, mode) },

View file

@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
buildConditionalToolSections,
buildIdentitySection,
buildMemoryFileSection,
buildProfileDirSection,
buildRuntimeSection,
buildSafetySection,
@ -63,7 +62,6 @@ describe("buildWorkspaceSection", () => {
expect(text).toContain("/path/to/profile");
expect(text).toContain("soul.md");
expect(text).toContain("user.md");
expect(text).toContain("memory.md");
expect(text).toContain("Rules here");
});
@ -78,14 +76,6 @@ describe("buildWorkspaceSection", () => {
});
});
describe("buildMemoryFileSection", () => {
it("returns empty in all modes (progressive disclosure)", () => {
// Memory content is no longer injected - agent reads memory.md on demand
expect(buildMemoryFileSection({ memory: "Key facts" }, "full")).toEqual([]);
expect(buildMemoryFileSection({ memory: "data" }, "minimal")).toEqual([]);
});
});
describe("buildSafetySection", () => {
it("returns safety text when enabled", () => {
const result = buildSafetySection(true);

View file

@ -27,7 +27,6 @@ const CORE_TOOL_SUMMARIES: Record<string, string> = {
process: "Manage background exec sessions",
web_search: "Search the web via Devv Search",
web_fetch: "Fetch and extract readable content from a URL",
memory_search: "Search memory files by keyword",
delegate: "Run tasks in parallel via sub-agents",
data: "Query structured financial and market data",
};
@ -42,7 +41,6 @@ const TOOL_ORDER = [
"process",
"web_search",
"web_fetch",
"memory_search",
"delegate",
"data",
];
@ -126,7 +124,7 @@ export function buildUserSection(
/**
* Workspace section workspace.md content with profile directory path.
* This is the primary profile content injected into system prompt.
* Other profile files (soul.md, user.md, memory.md) are read on demand.
* Other profile files (soul.md, user.md) are read on demand.
*/
export function buildWorkspaceSection(
profile: ProfileContent | undefined,
@ -155,13 +153,12 @@ export function buildWorkspaceSection(
"## Profile",
"",
`Your profile directory: \`${profileDir}\``,
"Use this as the base path for profile files (soul.md, user.md, memory.md, heartbeat.md, memory/*.md).",
"Use this as the base path for profile files (soul.md, user.md, workspace.md, heartbeat.md).",
"",
"Profile files:",
"- `soul.md` — Your identity and values",
"- `user.md` — Information about your user",
"- `workspace.md` — Guidelines and conventions (below)",
"- `memory.md` — Persistent knowledge",
"- `heartbeat.md` — Background heartbeat loop instructions",
"",
);
@ -176,18 +173,6 @@ export function buildWorkspaceSection(
return lines;
}
/**
* Memory section no longer injected into system prompt.
* Agent reads memory.md on demand from profile directory.
*/
export function buildMemoryFileSection(
_profile: ProfileContent | undefined,
_mode: SystemPromptMode,
): string[] {
// Progressive disclosure: agent reads memory.md on demand
return [];
}
/**
* Heartbeat section full mode only.
* Keeps heartbeat protocol explicit in the agent instructions.
@ -298,21 +283,6 @@ export function buildConditionalToolSections(
const toolSet = new Set(tools.map((t) => t.toLowerCase()));
const lines: string[] = [];
// Memory tools
if (toolSet.has("memory_search")) {
lines.push(
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos:",
"1. Run `memory_search` to find relevant entries in memory files",
"2. Use `read` to pull needed context",
"",
"To update memory, use `edit` on the appropriate file:",
"- `memory.md` — Long-term knowledge (decisions, preferences, important context)",
"- `memory/YYYY-MM-DD.md` — Daily logs and session notes",
"",
);
}
// Delegate tool (full mode only — sub-agents cannot delegate)
if (mode === "full" && toolSet.has("delegate")) {
lines.push(

View file

@ -62,7 +62,6 @@ export interface ProfileContent {
soul?: string | undefined;
user?: string | undefined;
workspace?: string | undefined;
memory?: string | undefined;
heartbeat?: string | undefined;
config?: ProfileConfig | undefined;
}

View file

@ -7,7 +7,6 @@ import { createProcessTool } from "./tools/process.js";
import { createGlobTool } from "./tools/glob.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
import { createDelegateTool } from "./tools/delegate.js";
import { createMemorySearchTool } from "./tools/memory-search.js";
import { createCronTool } from "./tools/cron/index.js";
import { createDataTool } from "./tools/data/index.js";
import { createSendFileTool } from "./tools/send-file.js";
@ -23,8 +22,6 @@ export { resolveModel } from "./providers/index.js";
/** Options for creating tools */
export interface CreateToolsOptions {
cwd: string;
/** Profile directory for memory_search tool (optional) */
profileDir?: string | undefined;
/** Whether this agent is a subagent (passed to delegate tool) */
isSubagent?: boolean | undefined;
/** Session ID of the agent (passed to delegate tool) */
@ -112,7 +109,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, profileDir, isSubagent, sessionId } = opts;
const { cwd, isSubagent, sessionId } = opts;
const baseTools = createCodingTools(cwd)
.filter((tool) => tool.name !== "bash")
@ -138,12 +135,6 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
dataTool as AgentTool<any>,
];
// Add memory_search tool if profileDir is provided
if (profileDir) {
const memorySearchTool = createMemorySearchTool(profileDir);
tools.push(memorySearchTool as AgentTool<any>);
}
// Add send_file tool if channel send callback is provided
if (opts.onChannelSendFile) {
const sendFileTool = createSendFileTool(opts.onChannelSendFile);
@ -166,10 +157,8 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
return tools;
}
/** Extended options for resolveTools that includes profileDir */
/** Extended options for resolveTools */
export interface ResolveToolsOptions extends AgentOptions {
/** Profile directory for memory_search tool (computed from profileId if not provided) */
profileDir?: string | undefined;
/** Run-log instance (forwarded to delegate tool) */
runLog?: import("./run-log.js").RunLog | undefined;
}
@ -189,7 +178,6 @@ export function resolveTools(options: ResolveToolsOptions): AgentTool<any>[] {
// Create all tools
const allTools = createAllTools({
cwd,
profileDir: options.profileDir,
isSubagent: options.isSubagent,
sessionId: options.sessionId,
provider: options.provider,

View file

@ -30,9 +30,6 @@ export const TOOL_GROUPS: Record<string, string[]> = {
// Web tools
"group:web": ["web_search", "web_fetch"],
// Memory tools (requires profile)
"group:memory": ["memory_search"],
// Subagent tools
"group:subagent": ["delegate"],

View file

@ -1,154 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createMemorySearchTool } from "./memory-search.js";
describe("memory_search tool", () => {
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `memory-search-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it("creates tool with correct name and description", () => {
const tool = createMemorySearchTool(testDir);
expect(tool.name).toBe("memory_search");
expect(tool.label).toBe("Memory Search");
expect(tool.description).toContain("memory files");
});
it("returns no matches when no memory files exist", async () => {
const tool = createMemorySearchTool(testDir);
const result = await tool.execute("test-call", { query: "test" }, undefined);
expect(result.details?.matches).toHaveLength(0);
expect(result.details?.filesSearched).toBe(0);
});
it("searches memory.md file", async () => {
// Create memory.md with test content
writeFileSync(
join(testDir, "memory.md"),
"# Memory\n\nUser prefers TypeScript over JavaScript.\n\nDecision: Use ESLint for linting.\n",
);
const tool = createMemorySearchTool(testDir);
const result = await tool.execute("test-call", { query: "TypeScript" }, undefined);
expect(result.details?.matches).toHaveLength(1);
expect(result.details?.matches[0]?.file).toBe("memory.md");
expect(result.details?.matches[0]?.content).toContain("TypeScript");
});
it("searches memory/*.md files", async () => {
// Create memory directory with daily logs
const memoryDir = join(testDir, "memory");
mkdirSync(memoryDir);
writeFileSync(
join(memoryDir, "2024-01-15.md"),
"# 2024-01-15\n\nDiscussed API design with team.\n",
);
writeFileSync(
join(memoryDir, "2024-01-16.md"),
"# 2024-01-16\n\nImplemented user authentication.\n",
);
const tool = createMemorySearchTool(testDir);
const result = await tool.execute("test-call", { query: "API" }, undefined);
expect(result.details?.matches).toHaveLength(1);
expect(result.details?.matches[0]?.file).toBe("memory/2024-01-15.md");
});
it("searches both memory.md and memory/*.md", async () => {
// Create memory.md
writeFileSync(join(testDir, "memory.md"), "Important: Always test code.\n");
// Create memory directory
const memoryDir = join(testDir, "memory");
mkdirSync(memoryDir);
writeFileSync(join(memoryDir, "2024-01-15.md"), "Remember to test before deploy.\n");
const tool = createMemorySearchTool(testDir);
const result = await tool.execute("test-call", { query: "test" }, undefined);
expect(result.details?.matches).toHaveLength(2);
expect(result.details?.filesSearched).toBe(2);
});
it("is case-insensitive by default", async () => {
writeFileSync(join(testDir, "memory.md"), "User prefers TYPESCRIPT.\n");
const tool = createMemorySearchTool(testDir);
const result = await tool.execute("test-call", { query: "typescript" }, undefined);
expect(result.details?.matches).toHaveLength(1);
});
it("supports case-sensitive search", async () => {
writeFileSync(join(testDir, "memory.md"), "User prefers TYPESCRIPT.\n");
const tool = createMemorySearchTool(testDir);
// Case-sensitive search should not match
const result1 = await tool.execute(
"test-call",
{ query: "typescript", caseSensitive: true },
undefined,
);
expect(result1.details?.matches).toHaveLength(0);
// Case-sensitive search should match
const result2 = await tool.execute(
"test-call",
{ query: "TYPESCRIPT", caseSensitive: true },
undefined,
);
expect(result2.details?.matches).toHaveLength(1);
});
it("includes context lines in results", async () => {
writeFileSync(
join(testDir, "memory.md"),
"Line 1\nLine 2\nMatch here\nLine 4\nLine 5\n",
);
const tool = createMemorySearchTool(testDir);
const result = await tool.execute("test-call", { query: "Match" }, undefined);
expect(result.details?.matches).toHaveLength(1);
expect(result.details?.matches[0]?.context.before).toContain("Line 2");
expect(result.details?.matches[0]?.context.after).toContain("Line 4");
});
it("respects maxResults limit", async () => {
// Create file with multiple matches
writeFileSync(
join(testDir, "memory.md"),
"test line 1\ntest line 2\ntest line 3\ntest line 4\ntest line 5\n",
);
const tool = createMemorySearchTool(testDir);
const result = await tool.execute(
"test-call",
{ query: "test", maxResults: 2 },
undefined,
);
expect(result.details?.matches).toHaveLength(2);
expect(result.details?.totalMatches).toBe(5);
expect(result.details?.truncated).toBe(true);
});
it("throws error for empty query", async () => {
const tool = createMemorySearchTool(testDir);
await expect(tool.execute("test-call", { query: "" }, undefined)).rejects.toThrow(
"Query must not be empty",
);
});
});

View file

@ -1,276 +0,0 @@
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import * as fs from "fs/promises";
import * as path from "path";
import fg from "fast-glob";
const MemorySearchSchema = Type.Object({
query: Type.String({
description: "Search query - keywords or phrases to find in memory files.",
}),
maxResults: Type.Optional(
Type.Number({
description: "Maximum number of results to return. Defaults to 10.",
minimum: 1,
maximum: 50,
}),
),
caseSensitive: Type.Optional(
Type.Boolean({
description: "Whether the search is case-sensitive. Defaults to false.",
}),
),
});
type MemorySearchArgs = {
query: string;
maxResults?: number;
caseSensitive?: boolean;
};
export type MemorySearchMatch = {
file: string;
line: number;
content: string;
context: {
before: string[];
after: string[];
};
};
export type MemorySearchResult = {
matches: MemorySearchMatch[];
totalMatches: number;
filesSearched: number;
truncated: boolean;
};
const DEFAULT_MAX_RESULTS = 10;
const CONTEXT_LINES = 2;
/**
* Create a memory_search tool for searching memory files.
*
* @param profileDir - Profile directory containing memory.md and memory/ folder
*/
export function createMemorySearchTool(
profileDir: string,
): AgentTool<typeof MemorySearchSchema, MemorySearchResult> {
return {
name: "memory_search",
label: "Memory Search",
description:
"Search through memory files (memory.md and memory/*.md) for keywords or phrases. " +
"Use this before answering questions about prior work, decisions, dates, people, preferences, or todos. " +
"Returns matching lines with context.",
parameters: MemorySearchSchema,
execute: async (_toolCallId, args, _signal) => {
const { query, maxResults, caseSensitive } = args as MemorySearchArgs;
if (!query || query.trim() === "") {
throw new Error("Query must not be empty");
}
const limit = Math.min(maxResults || DEFAULT_MAX_RESULTS, 50);
const searchQuery = caseSensitive ? query : query.toLowerCase();
// Find all memory files
const memoryFiles = await findMemoryFiles(profileDir);
if (memoryFiles.length === 0) {
return {
content: [{ type: "text", text: "No memory files found." }],
details: {
matches: [],
totalMatches: 0,
filesSearched: 0,
truncated: false,
},
};
}
// Search each file
const allMatches: MemorySearchMatch[] = [];
for (const file of memoryFiles) {
const matches = await searchFile(file, searchQuery, caseSensitive ?? false, profileDir);
allMatches.push(...matches);
}
// Sort by relevance (files with more matches first, then by line number)
allMatches.sort((a, b) => {
if (a.file !== b.file) {
// Count matches per file
const aCount = allMatches.filter((m) => m.file === a.file).length;
const bCount = allMatches.filter((m) => m.file === b.file).length;
return bCount - aCount;
}
return a.line - b.line;
});
const totalMatches = allMatches.length;
const truncated = allMatches.length > limit;
const limitedMatches = allMatches.slice(0, limit);
// Format output
const output = formatSearchResults(limitedMatches, totalMatches, truncated, memoryFiles.length);
return {
content: [{ type: "text", text: output }],
details: {
matches: limitedMatches,
totalMatches,
filesSearched: memoryFiles.length,
truncated,
},
};
},
};
}
/**
* Find all memory files in the profile directory.
*/
async function findMemoryFiles(profileDir: string): Promise<string[]> {
const files: string[] = [];
// Check for memory.md in profile root
const memoryMd = path.join(profileDir, "memory.md");
try {
await fs.access(memoryMd);
files.push(memoryMd);
} catch {
// File doesn't exist
}
// Check for memory/*.md files
const memoryDir = path.join(profileDir, "memory");
try {
await fs.access(memoryDir);
const mdFiles = await fg("*.md", {
cwd: memoryDir,
onlyFiles: true,
absolute: true,
});
files.push(...mdFiles);
} catch {
// Directory doesn't exist
}
return files;
}
/**
* Search a single file for the query.
*/
async function searchFile(
filePath: string,
query: string,
caseSensitive: boolean,
profileDir: string,
): Promise<MemorySearchMatch[]> {
const matches: MemorySearchMatch[] = [];
try {
const content = await fs.readFile(filePath, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const searchLine = caseSensitive ? line : line.toLowerCase();
if (searchLine.includes(query)) {
// Get context lines
const beforeLines: string[] = [];
const afterLines: string[] = [];
for (let j = Math.max(0, i - CONTEXT_LINES); j < i; j++) {
beforeLines.push(lines[j]!);
}
for (let j = i + 1; j <= Math.min(lines.length - 1, i + CONTEXT_LINES); j++) {
afterLines.push(lines[j]!);
}
// Get relative path for display
const relativePath = path.relative(profileDir, filePath);
matches.push({
file: relativePath,
line: i + 1, // 1-indexed
content: line,
context: {
before: beforeLines,
after: afterLines,
},
});
}
}
} catch (err) {
// Skip files that can't be read
console.error(`Failed to read ${filePath}:`, err);
}
return matches;
}
/**
* Format search results for display.
*/
function formatSearchResults(
matches: MemorySearchMatch[],
totalMatches: number,
truncated: boolean,
filesSearched: number,
): string {
if (matches.length === 0) {
return `No matches found in ${filesSearched} memory file(s).`;
}
const lines: string[] = [];
lines.push(`Found ${totalMatches} match(es) in ${filesSearched} file(s):`);
if (truncated) {
lines.push(`(Showing first ${matches.length} results)`);
}
lines.push("");
// Group by file
const byFile = new Map<string, MemorySearchMatch[]>();
for (const match of matches) {
const existing = byFile.get(match.file) || [];
existing.push(match);
byFile.set(match.file, existing);
}
for (const [file, fileMatches] of byFile) {
lines.push(`## ${file}`);
lines.push("");
for (const match of fileMatches) {
lines.push(`**Line ${match.line}:**`);
// Show context before
if (match.context.before.length > 0) {
for (const ctx of match.context.before) {
lines.push(` ${ctx}`);
}
}
// Show matching line (highlighted)
lines.push(`> ${match.content}`);
// Show context after
if (match.context.after.length > 0) {
for (const ctx of match.context.after) {
lines.push(` ${ctx}`);
}
}
lines.push("");
}
}
return lines.join("\n");
}

View file

@ -21,7 +21,7 @@ export type AgentLogger = {
};
export type AgentOptions = {
/** Agent Profile ID - loads predefined identity, personality, memory and other configurations */
/** Agent Profile ID - loads predefined identity, personality, and other configurations */
profileId?: string | undefined;
/** Profile base directory, defaults to ~/.super-multica/agent-profiles */
profileBaseDir?: string | undefined;

View file

@ -9,7 +9,6 @@ import {
Search,
FolderOpen,
Globe,
Database,
GitBranch,
BarChart3,
ChevronRight,
@ -35,10 +34,6 @@ const TOOL_DISPLAY: Record<string, { label: string; icon: LucideIcon }> = {
glob: { label: "Glob", icon: Search },
web_search: { label: "WebSearch", icon: Globe },
web_fetch: { label: "WebFetch", icon: Globe },
memory_get: { label: "MemoryGet", icon: Database },
memory_set: { label: "MemorySet", icon: Database },
memory_delete: { label: "MemoryDelete", icon: Database },
memory_list: { label: "MemoryList", icon: Database },
delegate: { label: "Delegate", icon: GitBranch },
data: { label: "Data", icon: BarChart3 },
}

View file

@ -217,7 +217,7 @@ async function runTask(
enableSkills: false,
tools: {
// Only allow coding tools — no web, no cron, no sessions
deny: ["web_fetch", "web_search", "cron", "data", "delegate", "memory_search", "send_file"],
deny: ["web_fetch", "web_search", "cron", "data", "delegate", "send_file"],
},
};