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:
@@ -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)] }],
|
||||
|
||||
73
runners/agent-runner/src/memory-selection.ts
Normal file
73
runners/agent-runner/src/memory-selection.ts
Normal 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;
|
||||
}
|
||||
43
runners/agent-runner/test/memory-selection.test.ts
Normal file
43
runners/agent-runner/test/memory-selection.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user