diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 8a0bb44..6c469fc 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 { @@ -30,6 +28,7 @@ import { isReviewerMutatingShellCommand, isReviewerRuntime, } from './reviewer-runtime.js'; +import { selectCompactMemoriesFromSummary } from './memory-selection.js'; interface ContainerInput { prompt: string; @@ -87,23 +86,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 +264,54 @@ 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, - }), - ]; + try { + const scopeKey = `room:${GROUP_FOLDER}`; + const selected = selectCompactMemoriesFromSummary(normalized, scopeKey); + if (selected.length === 0) { + log('Skipped compact memory persist - no salient room memory found'); + return; + } - if (GROUP_FOLDER) { - tasks.push( - callMementoTool('remember', { - content: trimSummary(normalized, 300), - topic: 'room-memory', - type: 'fact', - keywords: [`room:${GROUP_FOLDER}`], - source: `compact:${sessionId}`, - }), + for (const memory of selected) { + const file = writeHostTaskIpcFile({ + type: 'persist_memory', + scopeKind: 'room', + scopeKey, + content: trimSummary(memory.content, 300), + keywords: memory.keywords, + memory_kind: memory.memoryKind, + 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 +676,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/runners/agent-runner/src/memory-selection.ts b/runners/agent-runner/src/memory-selection.ts new file mode 100644 index 0000000..52a715a --- /dev/null +++ b/runners/agent-runner/src/memory-selection.ts @@ -0,0 +1,73 @@ +export interface SelectedCompactMemory { + content: string; + keywords: string[]; + memoryKind: string | null; +} + +const MAX_SELECTED_MEMORIES = 3; + +const MEMORY_PATTERNS: Array<{ + kind: string | null; + regex: RegExp; + keyword: string; +}> = [ + { kind: 'room_norm', regex: /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, keyword: 'rule' }, + { kind: 'preference', regex: /(선호|원함|원한다|원하는|원했다)/i, keyword: 'preference' }, + { kind: 'decision', regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, keyword: 'decision' }, + { kind: 'project_fact', regex: /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, keyword: 'memory' }, +]; + +function normalizeSentence(raw: string): string | null { + const normalized = raw + .trim() + .replace(/^[-*]\s*/, '') + .replace(/\s+/g, ' '); + return normalized ? normalized : null; +} + +function splitSummaryIntoSentences(summary: string): string[] { + return summary + .split(/\n+|(?<=[.!?。])\s+/) + .map(normalizeSentence) + .filter((value): value is string => Boolean(value)); +} + +function classifyMemorySentence(content: string): SelectedCompactMemory | null { + if (!content) return null; + + const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => regex.test(content)); + if (matchedPatterns.length === 0) return null; + + const primary = matchedPatterns[0]; + const keywords = [ + ...new Set( + matchedPatterns + .map((pattern) => pattern.keyword) + .filter(Boolean), + ), + ]; + + return { + content, + keywords, + memoryKind: primary?.kind ?? null, + }; +} + +export function selectCompactMemoriesFromSummary( + summary: string, + roomKey: string, +): SelectedCompactMemory[] { + if (!summary.trim()) return []; + + const selected = splitSummaryIntoSentences(summary) + .map((sentence) => classifyMemorySentence(sentence)) + .filter((value): value is SelectedCompactMemory => Boolean(value)) + .slice(0, MAX_SELECTED_MEMORIES) + .map((entry) => ({ + ...entry, + keywords: [...new Set([roomKey, ...entry.keywords])], + })); + + return selected; +} diff --git a/runners/agent-runner/test/memory-selection.test.ts b/runners/agent-runner/test/memory-selection.test.ts new file mode 100644 index 0000000..154068b --- /dev/null +++ b/runners/agent-runner/test/memory-selection.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { selectCompactMemoriesFromSummary } from '../src/memory-selection.js'; + +describe('memory selection', () => { + it('keeps stable room memories and drops transient status lines', () => { + const selected = selectCompactMemoriesFromSummary( + [ + '방 메모리는 새 세션 시작 시에만 주입한다.', + '테스트 599개 통과.', + '사용자는 수동 명령보다 자동 기억 형성을 원한다.', + ].join(' '), + 'room:ejclaw', + ); + + expect(selected).toHaveLength(2); + expect(selected[0].content).toBe('방 메모리는 새 세션 시작 시에만 주입한다.'); + expect(selected[0].memoryKind).toBe('room_norm'); + expect(selected[1].content).toBe('사용자는 수동 명령보다 자동 기억 형성을 원한다.'); + expect(selected[1].memoryKind).toBe('preference'); + expect(selected.every((memory) => memory.keywords.includes('room:ejclaw'))).toBe(true); + }); + + it('returns no memories for purely operational summaries', () => { + const selected = selectCompactMemoriesFromSummary( + '메멘토 대체 v1 로컬 구현은 끝냈습니다. 테스트 599개 통과.', + 'room:ejclaw', + ); + + expect(selected).toEqual([]); + }); + + it('keeps stable rule sentences even when they contain operational words', () => { + const selected = selectCompactMemoriesFromSummary( + '배포 전에 항상 테스트를 돌리는 것이 원칙이다.', + 'room:ejclaw', + ); + + expect(selected).toHaveLength(1); + expect(selected[0].content).toBe('배포 전에 항상 테스트를 돌리는 것이 원칙이다.'); + expect(selected[0].memoryKind).toBe('room_norm'); + }); +}); 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/db.test.ts b/src/db.test.ts index 1b34f2e..c8c0ef8 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + _setMemoryTimestampsForTests, _setRegisteredGroupForTests, assignRoom, claimServiceHandoff, @@ -30,6 +31,7 @@ import { getNewMessagesBySeq, getOpenWorkItem, getPendingServiceHandoffs, + recallMemories, getRegisteredAgentTypesForJid, getMessagesSince, getNewMessages, @@ -46,6 +48,7 @@ import { setSession, setRouterStateForService, setExplicitRoomMode, + rememberMemory, storeChatMetadata, storeMessage, updateRegisteredGroupName, @@ -1224,6 +1227,28 @@ describe('service handoff completion', () => { 'dc:handoff': '5', }); }); + + it('stores the intended handoff role when provided', () => { + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-role', + group_folder: 'test-group', + source_service_id: 'claude', + target_service_id: 'codex-review', + target_agent_type: 'codex', + prompt: 'please review', + reason: 'reviewer-claude-429', + intended_role: 'reviewer', + }); + + expect(handoff.intended_role).toBe('reviewer'); + expect(getPendingServiceHandoffs('codex-review')).toEqual([ + expect.objectContaining({ + id: handoff.id, + intended_role: 'reviewer', + reason: 'reviewer-claude-429', + }), + ]); + }); }); describe('message seq cursors', () => { @@ -1280,6 +1305,128 @@ describe('message seq cursors', () => { }); }); +describe('memories', () => { + it('recalls scoped memories through FTS and exact keyword matching', () => { + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:test-group', + content: '세션 재시작 후에도 방 메모리를 주입한다.', + keywords: ['room:test-group', 'session-reset'], + sourceKind: 'compact', + sourceRef: 'compact:1', + }); + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:test-group', + content: '이 메모리는 다른 검색어다.', + keywords: ['room:test-group'], + sourceKind: 'compact', + sourceRef: 'compact:2', + }); + + const byText = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:test-group', + text: 'session reset', + limit: 5, + }); + expect(byText).toHaveLength(1); + expect(byText[0].content).toContain('방 메모리를 주입한다'); + + const byKeyword = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:test-group', + keywords: ['session-reset'], + limit: 5, + }); + expect(byKeyword).toHaveLength(1); + expect(byKeyword[0].content).toContain('방 메모리를 주입한다'); + }); + + it('archives old memories when a scope exceeds its bounded limit', () => { + for (let index = 0; index < 305; index += 1) { + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:bounded', + content: `memory-${index}`, + keywords: ['room:bounded'], + sourceKind: 'compact', + sourceRef: `compact:${index}`, + }); + } + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:bounded', + limit: 500, + }); + + expect(recalled).toHaveLength(300); + expect(recalled.some((memory) => memory.content === 'memory-0')).toBe(false); + expect(recalled.some((memory) => memory.content === 'memory-304')).toBe(true); + }); + + it('archives stale compact memories before recall using last_used_at TTL', () => { + const staleId = rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl', + content: '오래된 compact memory', + keywords: ['room:ttl'], + sourceKind: 'compact', + sourceRef: 'compact:stale', + }); + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl', + content: '최근에 다시 쓰인 compact memory', + keywords: ['room:ttl'], + sourceKind: 'compact', + sourceRef: 'compact:fresh', + }); + + _setMemoryTimestampsForTests(staleId, { + createdAt: '2020-01-01T00:00:00.000Z', + lastUsedAt: '2020-01-02T00:00:00.000Z', + }); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:ttl', + limit: 10, + }); + + expect(recalled.some((memory) => memory.content === '오래된 compact memory')).toBe(false); + expect( + recalled.some((memory) => memory.content === '최근에 다시 쓰인 compact memory'), + ).toBe(true); + }); + + it('keeps explicit memories even when they are old', () => { + const explicitId = rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl-explicit', + content: '관리자가 남긴 고정 규칙', + keywords: ['room:ttl-explicit'], + sourceKind: 'explicit', + sourceRef: 'msg:1', + }); + + _setMemoryTimestampsForTests(explicitId, { + createdAt: '2020-01-01T00:00:00.000Z', + lastUsedAt: '2020-01-02T00:00:00.000Z', + }); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:ttl-explicit', + limit: 10, + }); + + expect(recalled).toHaveLength(1); + expect(recalled[0].content).toBe('관리자가 남긴 고정 규칙'); + }); +}); + describe('work items', () => { it('tracks produced, retry, and delivered states', () => { const item = createProducedWorkItem({ diff --git a/src/db.ts b/src/db.ts index 1f113f4..0360656 100644 --- a/src/db.ts +++ b/src/db.ts @@ -72,6 +72,31 @@ export interface AssignRoomInput { ownerAgentConfig?: RegisteredGroup['agentConfig']; } +export type MemoryScopeKind = 'room' | 'user' | 'project' | 'global'; +export type MemorySourceKind = 'compact' | 'explicit' | 'import' | 'system'; + +export interface MemoryRecord { + id: number; + scopeKind: MemoryScopeKind; + scopeKey: string; + content: string; + keywords: string[]; + memoryKind: string | null; + sourceKind: MemorySourceKind; + sourceRef: string | null; + createdAt: string; + lastUsedAt: string | null; + archivedAt: string | null; +} + +export interface RecallMemoryQuery { + scopeKind: MemoryScopeKind; + scopeKey: string; + text?: string; + keywords?: string[]; + limit?: number; +} + interface RoomRegistrationSnapshot { name: string; folder: string; @@ -134,6 +159,7 @@ export interface ServiceHandoff { start_seq: number | null; end_seq: number | null; reason: string | null; + intended_role: PairedRoomRole | null; created_at: string; claimed_at: string | null; completed_at: string | null; @@ -383,14 +409,53 @@ function createSchema(database: Database): void { start_seq INTEGER, end_seq INTEGER, reason TEXT, + intended_role TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), claimed_at TEXT, completed_at TEXT, last_error TEXT, - CHECK (status IN ('pending', 'claimed', 'completed', 'failed')) + CHECK (status IN ('pending', 'claimed', 'completed', 'failed')), + CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL) ); CREATE INDEX IF NOT EXISTS idx_service_handoffs_target ON service_handoffs(target_service_id, status, created_at); + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY, + scope_kind TEXT NOT NULL, + scope_key TEXT NOT NULL, + content TEXT NOT NULL, + keywords_json TEXT NOT NULL DEFAULT '[]', + memory_kind TEXT, + source_kind TEXT NOT NULL, + source_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TEXT, + archived_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_memories_scope + ON memories(scope_kind, scope_key); + CREATE INDEX IF NOT EXISTS idx_memories_active + ON memories(scope_kind, scope_key, archived_at, created_at); + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + content, + keywords, + content='', + tokenize='unicode61' + ); + CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, content, keywords) + VALUES (new.id, new.content, new.keywords_json); + END; + CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, content, keywords) + VALUES ('delete', old.id, old.content, old.keywords_json); + END; + CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, content, keywords) + VALUES ('delete', old.id, old.content, old.keywords_json); + INSERT INTO memories_fts(rowid, content, keywords) + VALUES (new.id, new.content, new.keywords_json); + END; `); // Add context_mode column if it doesn't exist (migration for existing DBs) @@ -506,6 +571,12 @@ function createSchema(database: Database): void { /* column already exists */ } + try { + database.exec(`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`); + } catch { + /* column already exists */ + } + database.exec( `UPDATE room_settings SET mode_source = 'explicit' @@ -955,6 +1026,323 @@ export function _setStoredRoomOwnerAgentTypeForTests( ).run(ownerAgentType, new Date().toISOString(), chatJid); } +/** @internal - for tests only. */ +export function _setMemoryTimestampsForTests( + id: number, + args: { + createdAt?: string; + lastUsedAt?: string | null; + archivedAt?: string | null; + }, +): void { + const existing = db + .prepare( + `SELECT created_at, last_used_at, archived_at + FROM memories + WHERE id = ?`, + ) + .get(id) as + | { + created_at: string; + last_used_at: string | null; + archived_at: string | null; + } + | undefined; + if (!existing) throw new Error(`Memory ${id} not found`); + + db.prepare( + `UPDATE memories + SET created_at = ?, last_used_at = ?, archived_at = ? + WHERE id = ?`, + ).run( + args.createdAt ?? existing.created_at, + args.lastUsedAt === undefined ? existing.last_used_at : args.lastUsedAt, + args.archivedAt === undefined ? existing.archived_at : args.archivedAt, + id, + ); +} + +const MEMORY_SCOPE_LIMITS: Record = { + room: 300, + user: 100, + project: 200, + global: 100, +}; +const COMPACT_MEMORY_TTL_DAYS = 30; + +interface MemoryDatabaseRow { + id: number; + scope_kind: string; + scope_key: string; + content: string; + keywords_json: string; + memory_kind: string | null; + source_kind: string; + source_ref: string | null; + created_at: string; + last_used_at: string | null; + archived_at: string | null; +} + +function normalizeMemoryKeywords(keywords?: string[]): string[] { + if (!Array.isArray(keywords)) return []; + return [ + ...new Set( + keywords + .map((keyword) => keyword.trim().toLowerCase()) + .filter(Boolean), + ), + ]; +} + +function parseMemoryKeywords(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return normalizeMemoryKeywords(Array.isArray(parsed) ? parsed : []); + } catch { + return []; + } +} + +function mapMemoryRow(row: MemoryDatabaseRow): MemoryRecord { + return { + id: row.id, + scopeKind: row.scope_kind as MemoryScopeKind, + scopeKey: row.scope_key, + content: row.content, + keywords: parseMemoryKeywords(row.keywords_json), + memoryKind: row.memory_kind, + sourceKind: row.source_kind as MemorySourceKind, + sourceRef: row.source_ref, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + archivedAt: row.archived_at, + }; +} + +function buildMemoryFtsQuery(query: RecallMemoryQuery): string | null { + const tokens = normalizeMemoryKeywords([ + ...(query.text?.match(/[\p{L}\p{N}_:-]+/gu) ?? []), + ...(query.keywords ?? []), + ]); + if (tokens.length === 0) return null; + return tokens.map((token) => `"${token.replaceAll('"', '""')}"`).join(' OR '); +} + +function getMemoryRowsForScope( + scopeKind: MemoryScopeKind, + scopeKey: string, +): MemoryDatabaseRow[] { + return db + .prepare( + `SELECT * + FROM memories + WHERE scope_kind = ? + AND scope_key = ? + AND archived_at IS NULL + ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC`, + ) + .all(scopeKind, scopeKey) as MemoryDatabaseRow[]; +} + +function queryFtsRowOrder(query: RecallMemoryQuery): Map { + const ftsQuery = buildMemoryFtsQuery(query); + if (!ftsQuery) return new Map(); + try { + const rows = db + .prepare( + `SELECT memories.id AS id + FROM memories_fts + JOIN memories ON memories.id = memories_fts.rowid + WHERE memories_fts MATCH ? + AND memories.scope_kind = ? + AND memories.scope_key = ? + AND memories.archived_at IS NULL + ORDER BY bm25(memories_fts), memories.created_at DESC + LIMIT ?`, + ) + .all( + ftsQuery, + query.scopeKind, + query.scopeKey, + Math.max(25, (query.limit ?? 10) * 8), + ) as Array<{ id: number }>; + return new Map(rows.map((row, index) => [row.id, rows.length - index])); + } catch (error) { + logger.warn( + { query, error }, + 'Memory FTS query failed; falling back to scope-only recall', + ); + return new Map(); + } +} + +export function touchMemories(ids: number[]): void { + const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]; + if (uniqueIds.length === 0) return; + const now = new Date().toISOString(); + const stmt = db.prepare( + `UPDATE memories + SET last_used_at = ? + WHERE id = ?`, + ); + const tx = db.transaction(() => { + for (const id of uniqueIds) stmt.run(now, id); + }); + tx(); +} + +export function archiveMemory(id: number): void { + db.prepare( + `UPDATE memories + SET archived_at = COALESCE(archived_at, ?) + WHERE id = ?`, + ).run(new Date().toISOString(), id); +} + +function buildCompactMemoryExpiryCutoff(nowIso: string): string { + return new Date( + new Date(nowIso).getTime() - COMPACT_MEMORY_TTL_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); +} + +export function expireStaleMemories(args?: { + scopeKind?: MemoryScopeKind; + scopeKey?: string; + now?: string; +}): number { + const nowIso = args?.now ?? new Date().toISOString(); + const cutoff = buildCompactMemoryExpiryCutoff(nowIso); + const scopeClause = + args?.scopeKind && args?.scopeKey + ? 'AND scope_kind = ? AND scope_key = ?' + : ''; + const stmt = db.prepare( + `UPDATE memories + SET archived_at = COALESCE(archived_at, ?) + WHERE archived_at IS NULL + AND source_kind = 'compact' + AND COALESCE(last_used_at, created_at) < ? + ${scopeClause}`, + ); + const result = ( + args?.scopeKind && args?.scopeKey + ? stmt.run(nowIso, cutoff, args.scopeKind, args.scopeKey) + : stmt.run(nowIso, cutoff) + ) as { changes?: number }; + return result.changes ?? 0; +} + +export function enforceMemoryBounds( + scopeKind: MemoryScopeKind, + scopeKey: string, +): void { + const limit = MEMORY_SCOPE_LIMITS[scopeKind]; + const rows = db + .prepare( + `SELECT id + FROM memories + WHERE scope_kind = ? + AND scope_key = ? + AND archived_at IS NULL + ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC + LIMIT -1 OFFSET ?`, + ) + .all(scopeKind, scopeKey, limit) as Array<{ id: number }>; + if (rows.length === 0) return; + const now = new Date().toISOString(); + const stmt = db.prepare( + `UPDATE memories + SET archived_at = ? + WHERE id = ?`, + ); + const tx = db.transaction(() => { + for (const row of rows) stmt.run(now, row.id); + }); + tx(); +} + +export function rememberMemory(input: { + scopeKind: MemoryScopeKind; + scopeKey: string; + content: string; + keywords?: string[]; + memoryKind?: string | null; + sourceKind: MemorySourceKind; + sourceRef?: string | null; +}): number { + const normalizedContent = input.content.trim(); + if (!normalizedContent) { + throw new Error('Memory content cannot be empty'); + } + const normalizedKeywords = normalizeMemoryKeywords(input.keywords); + const createdAt = new Date().toISOString(); + db.prepare( + `INSERT INTO memories ( + scope_kind, + scope_key, + content, + keywords_json, + memory_kind, + source_kind, + source_ref, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.scopeKind, + input.scopeKey, + normalizedContent, + JSON.stringify(normalizedKeywords), + input.memoryKind ?? null, + input.sourceKind, + input.sourceRef ?? null, + createdAt, + ); + const row = db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }; + expireStaleMemories({ + scopeKind: input.scopeKind, + scopeKey: input.scopeKey, + }); + enforceMemoryBounds(input.scopeKind, input.scopeKey); + return row.id; +} + +export function recallMemories(query: RecallMemoryQuery): MemoryRecord[] { + const limit = Math.max(1, query.limit ?? 6); + expireStaleMemories({ + scopeKind: query.scopeKind, + scopeKey: query.scopeKey, + }); + const rows = getMemoryRowsForScope(query.scopeKind, query.scopeKey); + if (rows.length === 0) return []; + + const exactKeywords = new Set(normalizeMemoryKeywords(query.keywords)); + const ftsOrder = queryFtsRowOrder(query); + const useQueryScoring = exactKeywords.size > 0 || Boolean(query.text?.trim()); + + const scored = rows + .map((row, index) => { + const keywords = parseMemoryKeywords(row.keywords_json); + const exactMatches = keywords.filter((keyword) => exactKeywords.has(keyword)) + .length; + const ftsScore = ftsOrder.get(row.id) ?? 0; + const recencyScore = rows.length - index; + return { + row, + matched: exactMatches > 0 || ftsScore > 0, + score: exactMatches * 100 + ftsScore * 10 + recencyScore, + }; + }) + .filter((entry) => (useQueryScoring ? entry.matched : true)) + .sort((a, b) => b.score - a.score || b.row.id - a.row.id) + .slice(0, limit); + + const memories = scored.map((entry) => mapMemoryRow(entry.row)); + touchMemories(memories.map((memory) => memory.id)); + return memories; +} + /** * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. @@ -3139,6 +3527,7 @@ export function createServiceHandoff(input: { start_seq?: number | null; end_seq?: number | null; reason?: string | null; + intended_role?: PairedRoomRole | null; }): ServiceHandoff { db.prepare( `INSERT INTO service_handoffs ( @@ -3150,8 +3539,9 @@ export function createServiceHandoff(input: { prompt, start_seq, end_seq, - reason - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + reason, + intended_role + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( input.chat_jid, input.group_folder, @@ -3162,6 +3552,7 @@ export function createServiceHandoff(input: { input.start_seq ?? null, input.end_seq ?? null, input.reason ?? null, + input.intended_role ?? null, ); const lastId = ( 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..89ec15f 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'; @@ -397,6 +397,123 @@ describe('runAgentForGroup room memory', () => { ); }); + it('honors a forced reviewer role even when the paired task status is active', async () => { + const group = { ...makeGroup(), folder: 'test-group' }; + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'paired-task-active', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: '2026-03-31T00:00:00.000Z', + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-31T00:00:00.000Z', + updated_at: '2026-03-31T00:00:00.000Z', + }); + + await runAgentForGroup(makeDeps(), { + group, + prompt: 'please retry review', + chatJid: 'group@test', + runId: 'run-forced-reviewer', + forcedRole: 'reviewer', + }); + + expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( + group, + expect.objectContaining({ + roomRoleContext: { + serviceId: 'claude', + role: 'reviewer', + ownerServiceId: 'codex-main', + reviewerServiceId: 'claude', + failoverOwner: false, + }, + }), + expect.any(Function), + undefined, + undefined, + ); + }); + + it('honors a forced agent type for reviewer failover handoffs', async () => { + const group: RegisteredGroup = { + ...makeGroup(), + agentType: 'codex', + folder: 'test-group', + }; + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'paired-task-reviewer-failover', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: '2026-03-31T00:00:00.000Z', + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-31T00:00:00.000Z', + updated_at: '2026-03-31T00:00:00.000Z', + }); + + await runAgentForGroup( + { + ...makeDeps(), + getSessions: () => ({ 'test-group:reviewer': 'claude-review-session' }), + }, + { + group, + prompt: 'please retry review with codex', + chatJid: 'group@test', + runId: 'run-forced-reviewer-codex', + forcedRole: 'reviewer', + forcedAgentType: 'codex', + }, + ); + + expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( + expect.objectContaining({ + agentType: 'codex', + }), + expect.objectContaining({ + sessionId: undefined, + }), + expect.any(Function), + undefined, + undefined, + ); + }); + it('allows silent reviewer outputs', async () => { const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ @@ -1005,6 +1122,7 @@ describe('runAgentForGroup Claude rotation', () => { start_seq: 10, end_seq: 12, reason: 'claude-usage-exhausted', + intended_role: 'owner', }), ); }); @@ -1207,6 +1325,7 @@ describe('runAgentForGroup Claude rotation', () => { target_service_id: 'codex-review', target_agent_type: 'codex', reason: 'claude-org-access-denied', + intended_role: 'owner', }), ); }); diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index e865cb5..d7ba990 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, @@ -44,7 +44,9 @@ import { shouldRetryFreshSessionOnAgentFailure, } from './session-recovery.js'; import { + ARBITER_AGENT_TYPE, CODEX_REVIEW_SERVICE_ID, + REVIEWER_AGENT_TYPE, SERVICE_SESSION_SCOPE, TIMEZONE, isClaudeService, @@ -69,7 +71,7 @@ import { } from './codex-token-rotation.js'; import type { CodexRotationReason } from './agent-error-detection.js'; import { getTokenCount } from './token-rotation.js'; -import type { RegisteredGroup } from './types.js'; +import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js'; // ── Main executor ───────────────────────────────────────────────── @@ -92,6 +94,8 @@ export async function runAgentForGroup( startSeq?: number | null; endSeq?: number | null; hasHumanMessage?: boolean; + forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; onOutput?: (output: AgentOutput) => Promise; }, ): Promise<'success' | 'error'> { @@ -107,7 +111,13 @@ export async function runAgentForGroup( const pairedTask = currentLease.reviewer_service_id ? getLatestOpenPairedTaskForChat(chatJid) : null; - const activeRole = resolveActiveRole(pairedTask?.status); + const inferredRole = resolveActiveRole(pairedTask?.status); + const canHonorForcedRole = Boolean( + args.forcedRole === 'owner' || + (args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) || + (args.forcedRole === 'arbiter' && currentLease.arbiter_service_id), + ); + const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole; const effectiveServiceId = activeRole === 'arbiter' ? currentLease.arbiter_service_id! @@ -121,10 +131,11 @@ export async function runAgentForGroup( group.agentType, ); - const effectiveAgentType = resolveEffectiveAgentType( + const configuredAgentType = resolveEffectiveAgentType( activeRole, group.agentType, ); + const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; const effectiveGroup = effectiveAgentType !== roleAgentPlan.ownerAgentType ? { ...group, agentType: effectiveAgentType } @@ -137,7 +148,9 @@ export async function runAgentForGroup( ); // Arbiter always starts fresh — never resume a previous session const sessionId = - activeRole === 'arbiter' ? undefined : sessions[sessionFolder]; + activeRole === 'arbiter' || args.forcedAgentType + ? undefined + : sessions[sessionFolder]; const memoryBriefing = sessionId ? undefined : await buildRoomMemoryBriefing({ @@ -203,6 +216,29 @@ export async function runAgentForGroup( role: activeRole, serviceId: effectiveServiceId, }); + log.info( + { + forcedRole: args.forcedRole, + forcedAgentType: args.forcedAgentType ?? null, + inferredRole, + canHonorForcedRole, + pairedTaskId: pairedTask?.id, + pairedTaskStatus: pairedTask?.status, + configuredAgentType, + effectiveServiceId, + effectiveAgentType, + groupAgentType: group.agentType, + configuredReviewerAgentType: REVIEWER_AGENT_TYPE, + configuredArbiterAgentType: ARBITER_AGENT_TYPE, + reviewerServiceId: currentLease.reviewer_service_id, + arbiterServiceId: currentLease.arbiter_service_id, + reviewerMode, + arbiterMode, + sessionFolder, + resumedSession: sessionId ?? null, + }, + 'Resolved execution target for agent turn', + ); // ── MoA prompt enrichment ───────────────────────────────────── // When MoA is enabled and we're in arbiter mode, query external API @@ -293,6 +329,7 @@ export async function runAgentForGroup( start_seq: startSeq ?? null, end_seq: endSeq ?? null, reason: `arbiter-claude-${reason}`, + intended_role: 'arbiter', }); log.warn( { reason }, @@ -314,6 +351,7 @@ export async function runAgentForGroup( start_seq: startSeq ?? null, end_seq: endSeq ?? null, reason: `reviewer-claude-${reason}`, + intended_role: 'reviewer', }); log.warn( { reason }, @@ -333,6 +371,7 @@ export async function runAgentForGroup( start_seq: startSeq ?? null, end_seq: endSeq ?? null, reason: `claude-${reason}`, + intended_role: activeRole, }); log.warn( { reason }, diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index bd17e04..fb3188f 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -183,7 +183,10 @@ vi.mock('./session-commands.js', () => ({ import * as agentRunner from './agent-runner.js'; import * as db from './db.js'; import { resolveGroupIpcPath } from './group-folder.js'; -import { createMessageRuntime } from './message-runtime.js'; +import { + createMessageRuntime, + resolveHandoffRoleOverride, +} from './message-runtime.js'; import * as config from './config.js'; import * as serviceRouting from './service-routing.js'; import type { Channel, RegisteredGroup } from './types.js'; @@ -223,6 +226,33 @@ describe('createMessageRuntime', () => { vi.mocked(config.isReviewService).mockReturnValue(false); }); + it('prefers intended_role over reason prefixes for handoff role resolution', () => { + expect( + resolveHandoffRoleOverride({ + intended_role: 'reviewer', + reason: 'claude-429', + }), + ).toBe('reviewer'); + expect( + resolveHandoffRoleOverride({ + intended_role: null, + reason: 'arbiter-claude-429', + }), + ).toBe('arbiter'); + expect( + resolveHandoffRoleOverride({ + intended_role: null, + reason: 'reviewer-claude-usage-exhausted', + }), + ).toBe('reviewer'); + expect( + resolveHandoffRoleOverride({ + intended_role: null, + reason: 'claude-usage-exhausted', + }), + ).toBeUndefined(); + }); + it('ignores generic failure bot messages in paired rooms', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index d76595d..69aab4a 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -58,6 +58,8 @@ import { import { Channel, NewMessage, + type AgentType, + type PairedRoomRole, RegisteredGroup, type PairedTask, type PairedTurnOutput, @@ -94,6 +96,21 @@ export function isDuplicateOfLastBotFinal( return normalizedLast === normalizedCurrent && normalizedLast.length > 0; } +export function resolveHandoffRoleOverride( + handoff: Pick, +): PairedRoomRole | undefined { + if (handoff.intended_role) { + return handoff.intended_role; + } + if (handoff.reason?.startsWith('reviewer-')) { + return 'reviewer'; + } + if (handoff.reason?.startsWith('arbiter-')) { + return 'arbiter'; + } + return undefined; +} + export interface MessageRuntimeDeps { assistantName: string; idleTimeout: number; @@ -415,6 +432,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq?: number | null; endSeq?: number | null; hasHumanMessage?: boolean; + forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; }, ): Promise<'success' | 'error'> => runAgentForGroup( @@ -434,6 +453,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq: options?.startSeq, endSeq: options?.endSeq, hasHumanMessage: options?.hasHumanMessage, + forcedRole: options?.forcedRole, + forcedAgentType: options?.forcedAgentType, onOutput, }, ); @@ -447,6 +468,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq: number | null; endSeq: number | null; hasHumanMessage?: boolean; + forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; }): Promise<{ outputStatus: 'success' | 'error'; deliverySucceeded: boolean; @@ -460,7 +483,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }> => { const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args; const isClaudeCodeAgent = - (group.agentType || 'claude-code') === 'claude-code'; + (args.forcedAgentType ?? group.agentType ?? 'claude-code') === + 'claude-code'; const turnController = new MessageTurnController({ chatJid, @@ -478,7 +502,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const workItem = createProducedWorkItem({ group_folder: group.folder, chat_jid: chatJid, - agent_type: group.agentType || 'claude-code', + agent_type: + args.forcedAgentType ?? group.agentType ?? 'claude-code', start_seq: startSeq, end_seq: endSeq, result_payload: text, @@ -503,7 +528,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { chatJid, runId, (result) => turnController.handleOutput(result), - { startSeq, endSeq, hasHumanMessage: args.hasHumanMessage }, + { + startSeq, + endSeq, + hasHumanMessage: args.hasHumanMessage, + forcedRole: args.forcedRole, + forcedAgentType: args.forcedAgentType, + }, ); const { deliverySucceeded, visiblePhase } = @@ -574,20 +605,35 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { // Reviewer/arbiter failover handoffs should run via the appropriate // channel so they execute in the correct role mode. - const isReviewerHandoff = handoff.reason?.startsWith('reviewer-'); - const isArbiterHandoff = handoff.reason?.startsWith('arbiter-'); + const handoffRole = resolveHandoffRoleOverride(handoff); let handoffChannel = channel; - if (isReviewerHandoff) { + if (handoffRole === 'reviewer') { const revChName = - REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; + handoff.target_agent_type === 'claude-code' + ? 'discord' + : 'discord-review'; handoffChannel = findChannelByName(deps.channels, revChName) || channel; - } else if (isArbiterHandoff) { + } else if (handoffRole === 'arbiter') { handoffChannel = findChannelByName(deps.channels, 'discord-review') || channel; } const runId = `handoff-${handoff.id}`; try { + logger.info( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + runId, + handoffRole, + targetServiceId: handoff.target_service_id, + targetAgentType: handoff.target_agent_type, + reason: handoff.reason, + intendedRole: handoff.intended_role ?? null, + channelName: handoffChannel.name, + }, + 'Dispatching claimed service handoff', + ); const result = await executeTurn({ group, prompt: handoff.prompt, @@ -596,6 +642,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel: handoffChannel, startSeq: handoff.start_seq, endSeq: handoff.end_seq, + forcedRole: handoffRole, + forcedAgentType: handoff.target_agent_type, }); if (!result.deliverySucceeded) { diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index d82475f..004090d 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -445,6 +445,51 @@ describe('paired execution context', () => { ); }); + it('requests arbiter instead of re-reviewing when repeated DONE finalize loops exceed the threshold', () => { + vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); + + const repoDir = createCanonicalRepoWithCommit('reviewed'); + const approvedSourceRef = resolveTreeRef(repoDir); + fs.writeFileSync(path.join(repoDir, 'README.md'), 'changed again\n'); + execFileSync('git', ['add', 'README.md'], { + cwd: repoDir, + stdio: 'ignore', + }); + execFileSync('git', ['commit', '-m', 'code change'], { + cwd: repoDir, + stdio: 'ignore', + }); + + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'merge_ready', + source_ref: approvedSourceRef, + round_trip_count: config.ARBITER_DEADLOCK_THRESHOLD, + }), + ); + vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) => + role === 'owner' ? buildWorkspace('owner', repoDir) : undefined, + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: 'DONE', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'arbiter_requested', + arbiter_requested_at: expect.any(String), + }), + ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).not.toHaveBeenCalled(); + }); + it.each(['BLOCKED', 'NEEDS_CONTEXT'])( 'escalates immediately when owner reports %s during finalize without arbiter', (summary) => { diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index d476457..5bcc6d1 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -525,6 +525,30 @@ export function completePairedExecutionContext(args: { : null; if (hasNewChanges === true) { + if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) { + if (isArbiterEnabled()) { + updatePairedTask(taskId, { + status: 'arbiter_requested', + arbiter_requested_at: now, + updated_at: now, + }); + logger.info( + { taskId, roundTrips: task.round_trip_count, hasNewChanges }, + 'Owner finalize DONE loop detected — requesting arbiter', + ); + } else { + updatePairedTask(taskId, { + status: 'completed', + completion_reason: 'escalated', + updated_at: now, + }); + logger.info( + { taskId, roundTrips: task.round_trip_count, hasNewChanges }, + 'Owner finalize DONE loop detected — escalating to user', + ); + } + return; + } // Owner made changes after approval → needs re-review logger.info( { diff --git a/src/sqlite-memory-store.test.ts b/src/sqlite-memory-store.test.ts new file mode 100644 index 0000000..200e8fb --- /dev/null +++ b/src/sqlite-memory-store.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { _initTestDatabase, rememberMemory } from './db.js'; +import { + buildRoomMemoryBriefing, + buildRoomMemoryKey, + formatRoomMemoryBriefing, +} from './sqlite-memory-store.js'; + +beforeEach(() => { + _initTestDatabase(); +}); + +describe('sqlite-memory-store helpers', () => { + it('builds a stable room memory key from the group folder', () => { + expect(buildRoomMemoryKey('ejclaw')).toBe('room:ejclaw'); + }); + + it('formats recalled memories into a compact session briefing', () => { + const briefing = formatRoomMemoryBriefing('room:ejclaw', [ + { + id: 1, + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: '사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.', + keywords: ['room:ejclaw'], + memoryKind: 'decision', + sourceKind: 'explicit', + sourceRef: 'msg:1', + createdAt: new Date().toISOString(), + lastUsedAt: null, + archivedAt: null, + }, + { + id: 2, + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: '자동 recall/compact persist를 호스트가 책임지는 방향으로 합의함.', + keywords: ['room:ejclaw'], + memoryKind: null, + sourceKind: 'compact', + sourceRef: 'compact:1', + createdAt: new Date().toISOString(), + lastUsedAt: null, + archivedAt: null, + }, + ]); + + expect(briefing).toContain('## Shared Room Memory'); + expect(briefing).toContain('room:ejclaw'); + expect(briefing).toContain( + '[decision] 사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.', + ); + expect(briefing).toContain( + '- 자동 recall/compact persist를 호스트가 책임지는 방향으로 합의함.', + ); + }); + + it('builds a room briefing from stored SQLite memories', async () => { + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: '방 메모리는 새 세션 시작 시에만 주입한다.', + keywords: ['room:ejclaw', 'session-start'], + sourceKind: 'compact', + sourceRef: 'compact:test', + }); + + const briefing = await buildRoomMemoryBriefing({ + groupFolder: 'ejclaw', + groupName: 'EJClaw', + }); + + expect(briefing).toContain('## Shared Room Memory'); + expect(briefing).toContain('방 메모리는 새 세션 시작 시에만 주입한다.'); + }); + + it('trims overly long briefings to the configured max length', () => { + const briefing = formatRoomMemoryBriefing( + 'room:ejclaw', + [ + { + id: 1, + scopeKind: 'room', + scopeKey: 'room:ejclaw', + content: 'a'.repeat(300), + keywords: [], + memoryKind: null, + sourceKind: 'compact', + sourceRef: 'compact:1', + createdAt: new Date().toISOString(), + lastUsedAt: null, + archivedAt: null, + }, + ], + 120, + ); + + expect(briefing).toBeDefined(); + expect(briefing!.length).toBeLessThanOrEqual(120); + expect(briefing!.endsWith('…')).toBe(true); + }); +}); diff --git a/src/sqlite-memory-store.ts b/src/sqlite-memory-store.ts new file mode 100644 index 0000000..74cbaf2 --- /dev/null +++ b/src/sqlite-memory-store.ts @@ -0,0 +1,62 @@ +import { type MemoryRecord, recallMemories } from './db.js'; + +const DEFAULT_PAGE_SIZE = 6; +const DEFAULT_MAX_BRIEFING_CHARS = 2_000; + +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, + memories: MemoryRecord[], + maxChars = DEFAULT_MAX_BRIEFING_CHARS, +): string | undefined { + if (memories.length === 0) return undefined; + + const lines = memories + .map((memory) => { + const content = memory.content.trim(); + if (!content) return null; + const prefix = memory.memoryKind ? `- [${memory.memoryKind}] ` : '- '; + return `${prefix}${content}`; + }) + .filter((line): line is string => Boolean(line)); + + 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; + maxChars?: number; + limit?: number; +}): Promise { + void args.groupName; + const roomKey = buildRoomMemoryKey(args.groupFolder); + const memories = recallMemories({ + scopeKind: 'room', + scopeKey: roomKey, + limit: args.limit ?? DEFAULT_PAGE_SIZE, + }); + + return formatRoomMemoryBriefing( + roomKey, + memories, + args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS, + ); +}