feat: honor handoff agent type + local memory store

- fix: pass forcedRole/forcedAgentType through handoff execution chain
  so reviewer failover actually runs codex instead of retrying Claude
- fix: preserve intended_role in service handoff records
- feat: replace Memento MCP with local SQLite memory store
- feat: add memory selection with TTL decay
This commit is contained in:
Eyejoker
2026-04-01 03:59:47 +09:00
parent 0bcfcb27fe
commit cb0d294941
18 changed files with 1309 additions and 422 deletions

View File

@@ -17,8 +17,6 @@
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { fileURLToPath } from 'url';
import {
@@ -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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
function trimSummary(summary: string, maxChars: number): string {
if (summary.length <= maxChars) return summary;
return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
}
async function callMementoTool(
name: string,
args: Record<string, unknown>,
timeoutMs = MEMENTO_TIMEOUT_MS,
): Promise<boolean> {
if (!MEMENTO_SSE_URL || !MEMENTO_ACCESS_KEY) return false;
const transport = new SSEClientTransport(new URL(MEMENTO_SSE_URL), {
requestInit: {
headers: {
Authorization: `Bearer ${MEMENTO_ACCESS_KEY}`,
},
},
});
const client = new Client({ name: 'ejclaw-precompact', version: '1.0.0' });
try {
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
const result = await withTimeout(
client.callTool({
name,
arguments: args,
}) as Promise<MementoCallToolResult>,
timeoutMs,
`${name}/call`,
);
if (result.isError) {
log(`Memento tool returned error: ${name}`);
return false;
}
return true;
} catch (err) {
log(`Memento tool failed (${name}): ${err instanceof Error ? err.message : String(err)}`);
return false;
} finally {
await client.close().catch(() => {});
}
function writeHostTaskIpcFile(data: object): string {
fs.mkdirSync(HOST_TASKS_DIR, { recursive: true });
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
const filepath = path.join(HOST_TASKS_DIR, filename);
const tempPath = `${filepath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
fs.renameSync(tempPath, filepath);
return filename;
}
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
const normalized = summary.trim();
if (!normalized) return;
if (!normalized || !GROUP_FOLDER) return;
const tasks: Promise<boolean>[] = [
callMementoTool('reflect', {
summary: normalized,
}),
];
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)] }],

View File

@@ -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;
}