feat: auto memory pipeline — host-driven Memento recall/reflect

Add automatic memory integration so EJClaw host directly calls Memento
MCP for recall at session start and reflect on compact, removing
reliance on agent voluntary tool use.

Stage 1: New sessions get room memory briefing injected into system
prompt (CLAUDE.md / AGENTS.md) via host-side MCP client.

Stage 2: PreCompact hook automatically calls reflect + remember to
persist session summaries as room-memory fragments.

Also restores missing source files (token-rotation, codex-token-rotation,
claude-usage exports) to fix full build from source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-24 03:48:36 +09:00
parent 8c98ad9faf
commit 3467e245d5
14 changed files with 2406 additions and 178 deletions

223
src/memento-client.ts Normal file
View File

@@ -0,0 +1,223 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { readEnvFile } 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 env = readEnvFile(['MEMENTO_MCP_SSE_URL', 'MEMENTO_ACCESS_KEY']);
const sseUrl = process.env.MEMENTO_MCP_SSE_URL || env.MEMENTO_MCP_SSE_URL;
const accessKey = process.env.MEMENTO_ACCESS_KEY || env.MEMENTO_ACCESS_KEY;
cachedConfig =
sseUrl && accessKey
? {
sseUrl,
accessKey,
}
: null;
return cachedConfig;
}
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
function extractToolText(result: MementoCallToolResult): string | null {
if (!Array.isArray(result.content)) return null;
const text = result.content
.filter(
(block): block is MementoContentBlock & { text: string } =>
block.type === 'text' && typeof block.text === 'string',
)
.map((block) => block.text)
.join('\n')
.trim();
return text || null;
}
function parseToolJson<T>(
result: MementoCallToolResult,
toolName: string,
): T | null {
const text = extractToolText(result);
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch (error) {
logger.warn(
{ toolName, error, text },
'Failed to parse Memento tool response JSON',
);
return null;
}
}
async function callMementoTool<T>(
name: string,
args: Record<string, unknown>,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<T | null> {
const config = getMementoConfig();
if (!config) return null;
const transport = new SSEClientTransport(new URL(config.sseUrl), {
requestInit: {
headers: {
Authorization: `Bearer ${config.accessKey}`,
},
},
});
const client = new Client({ name: 'ejclaw-host', version: '1.0.0' });
try {
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
const result = await withTimeout(
client.callTool({
name,
arguments: args,
}) as Promise<MementoCallToolResult>,
timeoutMs,
`${name}/call`,
);
if (result.isError) {
logger.warn({ toolName: name, args }, 'Memento tool returned an error');
return null;
}
return parseToolJson<T>(result, name);
} catch (error) {
logger.warn({ toolName: name, args, error }, 'Memento tool call failed');
return null;
} finally {
await client.close().catch(() => {});
}
}
function trimToMaxChars(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return text.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
}
export function buildRoomMemoryKey(groupFolder: string): string {
return `room:${groupFolder}`;
}
export function formatRoomMemoryBriefing(
roomKey: string,
fragments: MementoRecallFragment[],
maxChars = DEFAULT_MAX_BRIEFING_CHARS,
): string | undefined {
if (fragments.length === 0) return undefined;
const lines = fragments
.map((fragment) => {
const meta = [fragment.type, fragment.topic].filter(Boolean).join(' / ');
const prefix = meta ? `- [${meta}] ` : '- ';
return `${prefix}${fragment.content.trim()}`;
})
.filter(Boolean);
if (lines.length === 0) return undefined;
const text = [
'## Shared Room Memory',
`Room key: \`${roomKey}\``,
'Treat this as background context for a fresh session start. The current conversation always takes precedence.',
...lines,
].join('\n');
return trimToMaxChars(text, maxChars);
}
export async function buildRoomMemoryBriefing(args: {
groupFolder: string;
groupName: string;
timeoutMs?: number;
maxChars?: number;
}): Promise<string | undefined> {
const roomKey = buildRoomMemoryKey(args.groupFolder);
const recallResponse = await callMementoTool<MementoRecallResponse>(
'recall',
{
text: `shared room memory for ${args.groupName} (${roomKey})`,
keywords: [roomKey],
pageSize: DEFAULT_PAGE_SIZE,
tokenBudget: DEFAULT_TOKEN_BUDGET,
includeLinks: false,
excludeSeen: false,
},
args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
);
if (!recallResponse?.success || !recallResponse.fragments?.length) {
return undefined;
}
return formatRoomMemoryBriefing(
roomKey,
recallResponse.fragments,
args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS,
);
}