refactor: replace Memento MCP with local memory

This commit is contained in:
Eyejoker
2026-03-31 23:22:50 +09:00
parent 42ed5ef113
commit c76126bd58
8 changed files with 160 additions and 407 deletions

View File

@@ -17,8 +17,6 @@
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { fileURLToPath } from 'url';
import {
@@ -87,23 +85,18 @@ interface AssistantContentBlock {
text?: string;
}
interface MementoCallToolResult {
isError?: boolean;
}
// Paths configurable via env vars.
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
const MEMENTO_SSE_URL = process.env.MEMENTO_MCP_SSE_URL || '';
const MEMENTO_ACCESS_KEY = process.env.MEMENTO_ACCESS_KEY || '';
const MEMENTO_TIMEOUT_MS = 4_000;
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
/** SSOT: src/agent-protocol.ts — keep in sync */
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
@@ -270,101 +263,45 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string |
return null;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
function trimSummary(summary: string, maxChars: number): string {
if (summary.length <= maxChars) return summary;
return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
}
async function callMementoTool(
name: string,
args: Record<string, unknown>,
timeoutMs = MEMENTO_TIMEOUT_MS,
): Promise<boolean> {
if (!MEMENTO_SSE_URL || !MEMENTO_ACCESS_KEY) return false;
const transport = new SSEClientTransport(new URL(MEMENTO_SSE_URL), {
requestInit: {
headers: {
Authorization: `Bearer ${MEMENTO_ACCESS_KEY}`,
},
},
});
const client = new Client({ name: 'ejclaw-precompact', version: '1.0.0' });
try {
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
const result = await withTimeout(
client.callTool({
name,
arguments: args,
}) as Promise<MementoCallToolResult>,
timeoutMs,
`${name}/call`,
);
if (result.isError) {
log(`Memento tool returned error: ${name}`);
return false;
}
return true;
} catch (err) {
log(`Memento tool failed (${name}): ${err instanceof Error ? err.message : String(err)}`);
return false;
} finally {
await client.close().catch(() => {});
}
function writeHostTaskIpcFile(data: object): string {
fs.mkdirSync(HOST_TASKS_DIR, { recursive: true });
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
const filepath = path.join(HOST_TASKS_DIR, filename);
const tempPath = `${filepath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
fs.renameSync(tempPath, filepath);
return filename;
}
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
const normalized = summary.trim();
if (!normalized) return;
if (!normalized || !GROUP_FOLDER) return;
const tasks: Promise<boolean>[] = [
callMementoTool('reflect', {
summary: normalized,
}),
];
if (GROUP_FOLDER) {
tasks.push(
callMementoTool('remember', {
try {
const scopeKey = `room:${GROUP_FOLDER}`;
const file = writeHostTaskIpcFile({
type: 'persist_memory',
scopeKind: 'room',
scopeKey,
content: trimSummary(normalized, 300),
topic: 'room-memory',
type: 'fact',
keywords: [`room:${GROUP_FOLDER}`],
source: `compact:${sessionId}`,
}),
keywords: [scopeKey],
source_kind: 'compact',
source_ref: sessionId ? `compact:${sessionId}` : null,
timestamp: new Date().toISOString(),
});
log(`Persisted compact memory via IPC (${file})`);
} catch (err) {
log(
`Failed to persist compact memory via IPC: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
const results = await Promise.allSettled(tasks);
const succeeded = results.filter(
(result) => result.status === 'fulfilled' && result.value,
).length;
if (succeeded > 0) {
log(`Persisted compact memory (${succeeded}/${results.length})`);
}
}
/**
@@ -729,18 +666,6 @@ async function runQuery(
}),
},
},
...(process.env.MEMENTO_MCP_SSE_URL
? {
'memento-mcp': {
command: process.env.MEMENTO_MCP_REMOTE_PATH || 'mcp-remote',
args: [
process.env.MEMENTO_MCP_SSE_URL,
'--header',
`Authorization:Bearer ${process.env.MEMENTO_ACCESS_KEY || ''}`,
],
},
}
: {}),
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],

View File

@@ -316,10 +316,6 @@ function prepareCodexSessionEnvironment(args: {
? fs.readFileSync(sessionConfigPath, 'utf-8')
: '';
toml = toml.replace(/\n?\[mcp_servers\.ejclaw\][\s\S]*?(?=\n\[|$)/, '');
toml = toml.replace(
/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/,
'',
);
const mcpSection = `
[mcp_servers.ejclaw]
command = "node"
@@ -333,24 +329,9 @@ EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)}
EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
EJCLAW_AGENT_TYPE = ${JSON.stringify(args.env.EJCLAW_AGENT_TYPE)}
`;
const mementoSseUrl =
args.envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL;
const mementoAccessKey =
args.envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || '';
const mementoRemotePath =
args.envVars.MEMENTO_MCP_REMOTE_PATH ||
process.env.MEMENTO_MCP_REMOTE_PATH ||
'mcp-remote';
const mementoSection = mementoSseUrl
? `
[mcp_servers.memento-mcp]
command = ${JSON.stringify(mementoRemotePath)}
args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}]
`
: '';
fs.writeFileSync(
sessionConfigPath,
toml.trimEnd() + '\n' + mcpSection + mementoSection,
toml.trimEnd() + '\n' + mcpSection,
);
}
@@ -474,9 +455,6 @@ export function prepareGroupEnvironment(
'CLAUDE_EFFORT',
'CODEX_MODEL',
'CODEX_EFFORT',
'MEMENTO_MCP_SSE_URL',
'MEMENTO_ACCESS_KEY',
'MEMENTO_MCP_REMOTE_PATH',
]);
const env = buildBaseRunnerEnv({

View File

@@ -7,6 +7,7 @@ import {
assignRoom,
createTask,
getAllTasks,
recallMemories,
getRegisteredAgentTypesForJid,
getRegisteredGroup,
getStoredRoomSettings,
@@ -196,6 +197,56 @@ describe('schedule_task authorization', () => {
});
});
describe('persist_memory authorization', () => {
it('allows a room runtime to persist memory for its own room scope', async () => {
await processTaskIpc(
{
type: 'persist_memory',
scopeKind: 'room',
scopeKey: 'room:other-group',
content: 'compact summary',
keywords: ['room:other-group'],
source_kind: 'compact',
source_ref: 'compact:test',
},
'other-group',
false,
deps,
);
const recalled = recallMemories({
scopeKind: 'room',
scopeKey: 'room:other-group',
limit: 10,
});
expect(recalled).toHaveLength(1);
expect(recalled[0].content).toBe('compact summary');
});
it('blocks persist_memory when scopeKey does not match the source group', async () => {
await processTaskIpc(
{
type: 'persist_memory',
scopeKind: 'room',
scopeKey: 'room:third-group',
content: 'should be denied',
keywords: ['room:third-group'],
source_kind: 'compact',
},
'other-group',
false,
deps,
);
const recalled = recallMemories({
scopeKind: 'room',
scopeKey: 'room:third-group',
limit: 10,
});
expect(recalled).toHaveLength(0);
});
});
// --- pause_task authorization ---
describe('pause_task authorization', () => {

View File

@@ -12,6 +12,7 @@ import {
deleteTask,
findDuplicateCiWatcher,
getTaskById,
rememberMemory,
updateTask,
} from './db.js';
import { isValidGroupFolder } from './group-folder.js';
@@ -303,6 +304,13 @@ export async function processTaskIpc(
owner_agent_type?: AgentType;
isMain?: boolean;
workDir?: string;
scopeKind?: string;
scopeKey?: string;
content?: string;
keywords?: string[];
memory_kind?: string | null;
source_kind?: string;
source_ref?: string | null;
},
sourceGroup: string, // Verified identity from IPC directory
isMain: boolean, // Verified from directory path
@@ -657,6 +665,74 @@ export async function processTaskIpc(
}
break;
case 'persist_memory': {
if (
data.scopeKind !== 'room' ||
typeof data.scopeKey !== 'string' ||
typeof data.content !== 'string'
) {
logger.warn(
{ sourceGroup, data },
'Invalid persist_memory request - missing required fields',
);
break;
}
const expectedScopeKey = `room:${sourceGroup}`;
if (data.scopeKey !== expectedScopeKey) {
logger.warn(
{ sourceGroup, scopeKey: data.scopeKey, expectedScopeKey },
'Unauthorized persist_memory attempt blocked',
);
break;
}
if (
data.source_kind !== undefined &&
data.source_kind !== 'compact' &&
data.source_kind !== 'explicit' &&
data.source_kind !== 'import' &&
data.source_kind !== 'system'
) {
logger.warn(
{ sourceGroup, sourceKind: data.source_kind },
'Invalid persist_memory request - unknown source_kind',
);
break;
}
if (Array.isArray(data.keywords) && !data.keywords.every((v) => typeof v === 'string')) {
logger.warn(
{ sourceGroup, keywords: data.keywords },
'Invalid persist_memory request - keywords must be strings',
);
break;
}
rememberMemory({
scopeKind: 'room',
scopeKey: data.scopeKey,
content: data.content,
keywords: data.keywords,
memoryKind:
typeof data.memory_kind === 'string' ? data.memory_kind : null,
sourceKind:
(data.source_kind as 'compact' | 'explicit' | 'import' | 'system' | undefined) ??
'compact',
sourceRef:
typeof data.source_ref === 'string' ? data.source_ref : null,
});
logger.info(
{
sourceGroup,
scopeKey: data.scopeKey,
sourceKind: data.source_kind ?? 'compact',
},
'Memory persisted via IPC',
);
break;
}
default:
logger.warn({ type: data.type }, 'Unknown IPC task type');
}

View File

@@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildRoomMemoryKey,
formatRoomMemoryBriefing,
} from './memento-client.js';
describe('memento-client helpers', () => {
it('builds a stable room memory key from the group folder', () => {
expect(buildRoomMemoryKey('ejclaw')).toBe('room:ejclaw');
});
it('formats recalled fragments into a compact session briefing', () => {
const briefing = formatRoomMemoryBriefing('room:ejclaw', [
{
id: 'frag-1',
content: '사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
type: 'decision',
topic: 'room-memory',
},
{
id: 'frag-2',
content: '자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.',
type: 'fact',
},
]);
expect(briefing).toContain('## Shared Room Memory');
expect(briefing).toContain('room:ejclaw');
expect(briefing).toContain(
'[decision / room-memory] 사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
);
expect(briefing).toContain(
'[fact] 자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.',
);
});
it('trims overly long briefings to the configured max length', () => {
const briefing = formatRoomMemoryBriefing(
'room:ejclaw',
[
{
id: 'frag-1',
content: 'a'.repeat(300),
type: 'fact',
},
],
120,
);
expect(briefing).toBeDefined();
expect(briefing!.length).toBeLessThanOrEqual(120);
expect(briefing!.endsWith('…')).toBe(true);
});
});

View File

@@ -1,222 +0,0 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { getEnv } from './env.js';
import { logger } from './logger.js';
const DEFAULT_TIMEOUT_MS = 4_000;
const DEFAULT_PAGE_SIZE = 6;
const DEFAULT_TOKEN_BUDGET = 1_200;
const DEFAULT_MAX_BRIEFING_CHARS = 2_000;
interface MementoConfig {
sseUrl: string;
accessKey: string;
}
interface MementoContentBlock {
type?: string;
text?: string;
}
interface MementoCallToolResult {
content?: MementoContentBlock[];
isError?: boolean;
}
interface MementoRecallFragment {
id: string;
content: string;
topic?: string;
type?: string;
importance?: number;
}
interface MementoRecallResponse {
success?: boolean;
fragments?: MementoRecallFragment[];
}
let cachedConfig: MementoConfig | null | undefined;
function getMementoConfig(): MementoConfig | null {
if (cachedConfig !== undefined) return cachedConfig;
const sseUrl = getEnv('MEMENTO_MCP_SSE_URL');
const accessKey = getEnv('MEMENTO_ACCESS_KEY');
cachedConfig =
sseUrl && accessKey
? {
sseUrl,
accessKey,
}
: null;
return cachedConfig;
}
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
function extractToolText(result: MementoCallToolResult): string | null {
if (!Array.isArray(result.content)) return null;
const text = result.content
.filter(
(block): block is MementoContentBlock & { text: string } =>
block.type === 'text' && typeof block.text === 'string',
)
.map((block) => block.text)
.join('\n')
.trim();
return text || null;
}
function parseToolJson<T>(
result: MementoCallToolResult,
toolName: string,
): T | null {
const text = extractToolText(result);
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch (error) {
logger.warn(
{ toolName, error, text },
'Failed to parse Memento tool response JSON',
);
return null;
}
}
async function callMementoTool<T>(
name: string,
args: Record<string, unknown>,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<T | null> {
const config = getMementoConfig();
if (!config) return null;
const transport = new SSEClientTransport(new URL(config.sseUrl), {
requestInit: {
headers: {
Authorization: `Bearer ${config.accessKey}`,
},
},
});
const client = new Client({ name: 'ejclaw-host', version: '1.0.0' });
try {
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
const result = await withTimeout(
client.callTool({
name,
arguments: args,
}) as Promise<MementoCallToolResult>,
timeoutMs,
`${name}/call`,
);
if (result.isError) {
logger.warn({ toolName: name, args }, 'Memento tool returned an error');
return null;
}
return parseToolJson<T>(result, name);
} catch (error) {
logger.warn({ toolName: name, args, error }, 'Memento tool call failed');
return null;
} finally {
await client.close().catch(() => {});
}
}
function trimToMaxChars(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return text.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
}
export function buildRoomMemoryKey(groupFolder: string): string {
return `room:${groupFolder}`;
}
export function formatRoomMemoryBriefing(
roomKey: string,
fragments: MementoRecallFragment[],
maxChars = DEFAULT_MAX_BRIEFING_CHARS,
): string | undefined {
if (fragments.length === 0) return undefined;
const lines = fragments
.map((fragment) => {
const meta = [fragment.type, fragment.topic].filter(Boolean).join(' / ');
const prefix = meta ? `- [${meta}] ` : '- ';
return `${prefix}${fragment.content.trim()}`;
})
.filter(Boolean);
if (lines.length === 0) return undefined;
const text = [
'## Shared Room Memory',
`Room key: \`${roomKey}\``,
'Treat this as background context for a fresh session start. The current conversation always takes precedence.',
...lines,
].join('\n');
return trimToMaxChars(text, maxChars);
}
export async function buildRoomMemoryBriefing(args: {
groupFolder: string;
groupName: string;
timeoutMs?: number;
maxChars?: number;
}): Promise<string | undefined> {
const roomKey = buildRoomMemoryKey(args.groupFolder);
const recallResponse = await callMementoTool<MementoRecallResponse>(
'recall',
{
text: `shared room memory for ${args.groupName} (${roomKey})`,
keywords: [roomKey],
pageSize: DEFAULT_PAGE_SIZE,
tokenBudget: DEFAULT_TOKEN_BUDGET,
includeLinks: false,
excludeSeen: false,
},
args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
);
if (!recallResponse?.success || !recallResponse.fragments?.length) {
return undefined;
}
return formatRoomMemoryBriefing(
roomKey,
recallResponse.fragments,
args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS,
);
}

View File

@@ -142,7 +142,7 @@ vi.mock('./codex-token-rotation.js', () => ({
markCodexTokenHealthy: vi.fn(),
}));
vi.mock('./memento-client.js', () => ({
vi.mock('./sqlite-memory-store.js', () => ({
buildRoomMemoryBriefing: vi.fn(),
}));
@@ -155,7 +155,7 @@ import * as agentRunner from './agent-runner.js';
import type { AgentOutput } from './agent-runner.js';
import * as codexTokenRotation from './codex-token-rotation.js';
import * as db from './db.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
import { runAgentForGroup } from './message-agent-executor.js';
import * as pairedExecutionContext from './paired-execution-context.js';
import * as sessionRecovery from './session-recovery.js';

View File

@@ -22,7 +22,7 @@ import {
} from './db.js';
import { GroupQueue } from './group-queue.js';
import { createScopedLogger } from './logger.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
import {
completePairedExecutionContext,
preparePairedExecutionContext,