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

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