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

View File

@@ -11,6 +11,7 @@ import { DATA_DIR } from './config.js';
import { getAllTasks } from './db.js';
import { GroupQueue } from './group-queue.js';
import { logger } from './logger.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import {
detectFallbackTrigger,
getActiveProvider,
@@ -21,6 +22,16 @@ import {
markPrimaryCooldown,
} from './provider-fallback.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import {
rotateCodexToken,
getCodexAccountCount,
markCodexTokenHealthy,
} from './codex-token-rotation.js';
import {
rotateToken,
getTokenCount,
markTokenHealthy,
} from './token-rotation.js';
import type { RegisteredGroup } from './types.js';
export interface MessageAgentExecutorDeps {
@@ -32,6 +43,14 @@ export interface MessageAgentExecutorDeps {
clearSession: (groupFolder: string) => void;
}
function isClaudeUsageExhaustedMessage(text: string): boolean {
const normalized = text.trim();
return (
/^you(?:'re| are) out of extra usage\b/i.test(normalized) ||
/^you(?:'ve| have) hit your limit\b/i.test(normalized)
);
}
export async function runAgentForGroup(
deps: MessageAgentExecutorDeps,
args: {
@@ -48,6 +67,12 @@ export async function runAgentForGroup(
(group.agentType || 'claude-code') === 'claude-code';
const sessions = deps.getSessions();
const sessionId = sessions[group.folder];
const memoryBriefing = sessionId
? undefined
: await buildRoomMemoryBriefing({
groupFolder: group.folder,
groupName: group.name,
}).catch(() => undefined);
const tasks = getAllTasks(group.agentType || 'claude-code');
writeTasksSnapshot(
@@ -86,6 +111,7 @@ export async function runAgentForGroup(
const agentInput = {
prompt,
sessionId,
memoryBriefing,
groupFolder: group.folder,
chatJid,
runId,
@@ -127,6 +153,20 @@ export async function runAgentForGroup(
) {
resetSessionRequested = true;
}
if (
canFallback &&
provider === 'claude' &&
output.status === 'success' &&
!sawOutput &&
!streamedTriggerReason &&
typeof output.result === 'string' &&
isClaudeUsageExhaustedMessage(output.result)
) {
streamedTriggerReason = {
reason: 'usage-exhausted',
};
return;
}
if (output.result !== null && output.result !== undefined) {
sawOutput = true;
} else if (
@@ -137,7 +177,6 @@ export async function runAgentForGroup(
sawSuccessNullResultWithoutOutput = true;
}
if (
provider === 'claude' &&
output.status === 'error' &&
!sawOutput &&
!streamedTriggerReason
@@ -299,6 +338,18 @@ export async function runAgentForGroup(
}
: detectFallbackTrigger(errMsg);
if (trigger.shouldFallback) {
// Try rotating token before falling back to another provider
if (getTokenCount() > 1 && rotateToken(errMsg)) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Rate-limited, retrying with rotated token',
);
const retryAttempt = await runAttempt('claude');
if (!retryAttempt.error) {
markTokenHealthy();
return 'success';
}
}
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
}
}
@@ -332,6 +383,19 @@ export async function runAgentForGroup(
return 'error';
}
if (
canFallback &&
provider === 'claude' &&
!primaryAttempt.sawOutput &&
primaryAttempt.streamedTriggerReason &&
output.status !== 'error'
) {
return runFallbackAttempt(
primaryAttempt.streamedTriggerReason.reason,
primaryAttempt.streamedTriggerReason.retryAfterMs,
);
}
if (
canFallback &&
provider === 'claude' &&
@@ -362,10 +426,40 @@ export async function runAgentForGroup(
}
: detectFallbackTrigger(output.error);
if (trigger.shouldFallback) {
if (getTokenCount() > 1 && rotateToken(output.error ?? undefined)) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Rate-limited (output error), retrying with rotated token',
);
const retryAttempt = await runAttempt('claude');
if (!retryAttempt.error) {
markTokenHealthy();
return 'success';
}
}
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
}
}
// Codex rate-limit rotation (non-Claude agents)
if (!isClaudeCodeAgent && getCodexAccountCount() > 1) {
const trigger = detectFallbackTrigger(output.error);
if (
trigger.shouldFallback &&
rotateCodexToken(output.error ?? undefined)
) {
logger.info(
{ chatJid, group: group.name, runId, reason: trigger.reason },
'Codex rate-limited, retrying with rotated account',
);
const retryAttempt = await runAttempt('codex');
if (!retryAttempt.error) {
markCodexTokenHealthy();
return 'success';
}
}
}
logger.error(
{
group: group.name,
@@ -379,5 +473,29 @@ export async function runAgentForGroup(
return 'error';
}
// Codex may report success but have streamed a rate-limit error.
// Rotate token and retry immediately with the new account.
if (
!isClaudeCodeAgent &&
primaryAttempt.streamedTriggerReason &&
getCodexAccountCount() > 1 &&
rotateCodexToken(output.error ?? undefined)
) {
logger.info(
{
chatJid,
group: group.name,
runId,
reason: primaryAttempt.streamedTriggerReason.reason,
},
'Codex rate-limited (streamed), retrying with rotated account',
);
const retryAttempt = await runAttempt('codex');
if (!retryAttempt.error) {
markCodexTokenHealthy();
return 'success';
}
}
return 'success';
}