diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 8a0bb44..18ab2e7 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -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(promise: Promise, timeoutMs: number, label: string): Promise { - return new Promise((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, - timeoutMs = MEMENTO_TIMEOUT_MS, -): Promise { - 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, - 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 { const normalized = summary.trim(); - if (!normalized) return; + if (!normalized || !GROUP_FOLDER) return; - const tasks: Promise[] = [ - callMementoTool('reflect', { - summary: normalized, - }), - ]; - - if (GROUP_FOLDER) { - tasks.push( - callMementoTool('remember', { - content: trimSummary(normalized, 300), - topic: 'room-memory', - type: 'fact', - keywords: [`room:${GROUP_FOLDER}`], - source: `compact:${sessionId}`, - }), + try { + const scopeKey = `room:${GROUP_FOLDER}`; + const file = writeHostTaskIpcFile({ + type: 'persist_memory', + scopeKind: 'room', + scopeKey, + content: trimSummary(normalized, 300), + 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)] }], diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index d1da400..0cae6cc 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -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({ diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 3b39ec3..d0eeecc 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -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', () => { diff --git a/src/ipc.ts b/src/ipc.ts index 829dd09..06528f8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -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'); } diff --git a/src/memento-client.test.ts b/src/memento-client.test.ts deleted file mode 100644 index 00c0212..0000000 --- a/src/memento-client.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/memento-client.ts b/src/memento-client.ts deleted file mode 100644 index 5c696c7..0000000 --- a/src/memento-client.ts +++ /dev/null @@ -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( - promise: Promise, - timeoutMs: number, - label: string, -): Promise { - return new Promise((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( - 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( - name: string, - args: Record, - timeoutMs = DEFAULT_TIMEOUT_MS, -): Promise { - 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, - timeoutMs, - `${name}/call`, - ); - - if (result.isError) { - logger.warn({ toolName: name, args }, 'Memento tool returned an error'); - return null; - } - - return parseToolJson(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 { - const roomKey = buildRoomMemoryKey(args.groupFolder); - const recallResponse = await callMementoTool( - '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, - ); -} diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 127034c..201cda5 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -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'; diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index e865cb5..bb2bce5 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -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,