refactor: replace Memento MCP with local memory
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 {
|
||||
@@ -87,23 +85,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 +263,45 @@ 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,
|
||||
}),
|
||||
];
|
||||
|
||||
if (GROUP_FOLDER) {
|
||||
tasks.push(
|
||||
callMementoTool('remember', {
|
||||
try {
|
||||
const scopeKey = `room:${GROUP_FOLDER}`;
|
||||
const file = writeHostTaskIpcFile({
|
||||
type: 'persist_memory',
|
||||
scopeKind: 'room',
|
||||
scopeKey,
|
||||
content: trimSummary(normalized, 300),
|
||||
topic: 'room-memory',
|
||||
type: 'fact',
|
||||
keywords: [`room:${GROUP_FOLDER}`],
|
||||
source: `compact:${sessionId}`,
|
||||
}),
|
||||
keywords: [scopeKey],
|
||||
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 +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: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
76
src/ipc.ts
76
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');
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user