refactor: replace Memento MCP with local memory
This commit is contained in:
@@ -17,8 +17,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
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 { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -87,23 +85,18 @@ interface AssistantContentBlock {
|
|||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MementoCallToolResult {
|
|
||||||
isError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paths configurable via env vars.
|
// Paths configurable via env vars.
|
||||||
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
|
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
|
||||||
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
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)
|
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
|
||||||
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
||||||
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
|
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_DIR = path.join(IPC_DIR, 'input');
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||||
const IPC_POLL_MS = 500;
|
const IPC_POLL_MS = 500;
|
||||||
|
const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
||||||
|
|
||||||
/** SSOT: src/agent-protocol.ts — keep in sync */
|
/** SSOT: src/agent-protocol.ts — keep in sync */
|
||||||
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
||||||
@@ -270,101 +263,45 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string |
|
|||||||
return null;
|
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 {
|
function trimSummary(summary: string, maxChars: number): string {
|
||||||
if (summary.length <= maxChars) return summary;
|
if (summary.length <= maxChars) return summary;
|
||||||
return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
|
return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callMementoTool(
|
function writeHostTaskIpcFile(data: object): string {
|
||||||
name: string,
|
fs.mkdirSync(HOST_TASKS_DIR, { recursive: true });
|
||||||
args: Record<string, unknown>,
|
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
|
||||||
timeoutMs = MEMENTO_TIMEOUT_MS,
|
const filepath = path.join(HOST_TASKS_DIR, filename);
|
||||||
): Promise<boolean> {
|
const tempPath = `${filepath}.tmp`;
|
||||||
if (!MEMENTO_SSE_URL || !MEMENTO_ACCESS_KEY) return false;
|
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
|
||||||
|
fs.renameSync(tempPath, filepath);
|
||||||
const transport = new SSEClientTransport(new URL(MEMENTO_SSE_URL), {
|
return filename;
|
||||||
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(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
|
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
|
||||||
const normalized = summary.trim();
|
const normalized = summary.trim();
|
||||||
if (!normalized) return;
|
if (!normalized || !GROUP_FOLDER) return;
|
||||||
|
|
||||||
const tasks: Promise<boolean>[] = [
|
try {
|
||||||
callMementoTool('reflect', {
|
const scopeKey = `room:${GROUP_FOLDER}`;
|
||||||
summary: normalized,
|
const file = writeHostTaskIpcFile({
|
||||||
}),
|
type: 'persist_memory',
|
||||||
];
|
scopeKind: 'room',
|
||||||
|
scopeKey,
|
||||||
if (GROUP_FOLDER) {
|
content: trimSummary(normalized, 300),
|
||||||
tasks.push(
|
keywords: [scopeKey],
|
||||||
callMementoTool('remember', {
|
source_kind: 'compact',
|
||||||
content: trimSummary(normalized, 300),
|
source_ref: sessionId ? `compact:${sessionId}` : null,
|
||||||
topic: 'room-memory',
|
timestamp: new Date().toISOString(),
|
||||||
type: 'fact',
|
});
|
||||||
keywords: [`room:${GROUP_FOLDER}`],
|
log(`Persisted compact memory via IPC (${file})`);
|
||||||
source: `compact:${sessionId}`,
|
} 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: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||||
|
|||||||
@@ -316,10 +316,6 @@ function prepareCodexSessionEnvironment(args: {
|
|||||||
? fs.readFileSync(sessionConfigPath, 'utf-8')
|
? fs.readFileSync(sessionConfigPath, 'utf-8')
|
||||||
: '';
|
: '';
|
||||||
toml = toml.replace(/\n?\[mcp_servers\.ejclaw\][\s\S]*?(?=\n\[|$)/, '');
|
toml = toml.replace(/\n?\[mcp_servers\.ejclaw\][\s\S]*?(?=\n\[|$)/, '');
|
||||||
toml = toml.replace(
|
|
||||||
/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
const mcpSection = `
|
const mcpSection = `
|
||||||
[mcp_servers.ejclaw]
|
[mcp_servers.ejclaw]
|
||||||
command = "node"
|
command = "node"
|
||||||
@@ -333,24 +329,9 @@ EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)}
|
|||||||
EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
|
EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
|
||||||
EJCLAW_AGENT_TYPE = ${JSON.stringify(args.env.EJCLAW_AGENT_TYPE)}
|
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(
|
fs.writeFileSync(
|
||||||
sessionConfigPath,
|
sessionConfigPath,
|
||||||
toml.trimEnd() + '\n' + mcpSection + mementoSection,
|
toml.trimEnd() + '\n' + mcpSection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,9 +455,6 @@ export function prepareGroupEnvironment(
|
|||||||
'CLAUDE_EFFORT',
|
'CLAUDE_EFFORT',
|
||||||
'CODEX_MODEL',
|
'CODEX_MODEL',
|
||||||
'CODEX_EFFORT',
|
'CODEX_EFFORT',
|
||||||
'MEMENTO_MCP_SSE_URL',
|
|
||||||
'MEMENTO_ACCESS_KEY',
|
|
||||||
'MEMENTO_MCP_REMOTE_PATH',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const env = buildBaseRunnerEnv({
|
const env = buildBaseRunnerEnv({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
assignRoom,
|
assignRoom,
|
||||||
createTask,
|
createTask,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
|
recallMemories,
|
||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
getRegisteredGroup,
|
getRegisteredGroup,
|
||||||
getStoredRoomSettings,
|
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 ---
|
// --- pause_task authorization ---
|
||||||
|
|
||||||
describe('pause_task authorization', () => {
|
describe('pause_task authorization', () => {
|
||||||
|
|||||||
76
src/ipc.ts
76
src/ipc.ts
@@ -12,6 +12,7 @@ import {
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
findDuplicateCiWatcher,
|
findDuplicateCiWatcher,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
|
rememberMemory,
|
||||||
updateTask,
|
updateTask,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
@@ -303,6 +304,13 @@ export async function processTaskIpc(
|
|||||||
owner_agent_type?: AgentType;
|
owner_agent_type?: AgentType;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
workDir?: string;
|
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
|
sourceGroup: string, // Verified identity from IPC directory
|
||||||
isMain: boolean, // Verified from directory path
|
isMain: boolean, // Verified from directory path
|
||||||
@@ -657,6 +665,74 @@ export async function processTaskIpc(
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -142,7 +142,7 @@ vi.mock('./codex-token-rotation.js', () => ({
|
|||||||
markCodexTokenHealthy: vi.fn(),
|
markCodexTokenHealthy: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./memento-client.js', () => ({
|
vi.mock('./sqlite-memory-store.js', () => ({
|
||||||
buildRoomMemoryBriefing: vi.fn(),
|
buildRoomMemoryBriefing: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ import * as agentRunner from './agent-runner.js';
|
|||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||||
import * as db from './db.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 { runAgentForGroup } from './message-agent-executor.js';
|
||||||
import * as pairedExecutionContext from './paired-execution-context.js';
|
import * as pairedExecutionContext from './paired-execution-context.js';
|
||||||
import * as sessionRecovery from './session-recovery.js';
|
import * as sessionRecovery from './session-recovery.js';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { createScopedLogger } from './logger.js';
|
import { createScopedLogger } from './logger.js';
|
||||||
import { buildRoomMemoryBriefing } from './memento-client.js';
|
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
||||||
import {
|
import {
|
||||||
completePairedExecutionContext,
|
completePairedExecutionContext,
|
||||||
preparePairedExecutionContext,
|
preparePairedExecutionContext,
|
||||||
|
|||||||
Reference in New Issue
Block a user