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:
1093
package-lock.json
generated
1093
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cron-parser": "^5.5.0",
|
||||
"discord.js": "^14.18.0",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
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';
|
||||
|
||||
interface ContainerInput {
|
||||
@@ -65,11 +67,19 @@ 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';
|
||||
// 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');
|
||||
@@ -221,6 +231,103 @@ 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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
|
||||
const normalized = summary.trim();
|
||||
if (!normalized) return;
|
||||
|
||||
const tasks: Promise<boolean>[] = [
|
||||
callMementoTool('reflect', {
|
||||
summary: normalized,
|
||||
}),
|
||||
];
|
||||
|
||||
if (GROUP_FOLDER) {
|
||||
tasks.push(
|
||||
callMementoTool('remember', {
|
||||
content: trimSummary(normalized, 300),
|
||||
topic: 'room-memory',
|
||||
type: 'fact',
|
||||
keywords: [`room:${GROUP_FOLDER}`],
|
||||
source: `compact:${sessionId}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive the full transcript to conversations/ before compaction.
|
||||
*/
|
||||
@@ -258,6 +365,10 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
log(`Archived conversation to ${filePath}`);
|
||||
|
||||
if (summary) {
|
||||
await persistCompactMemory(summary, sessionId);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ function prepareCodexSessionEnvironment(args: {
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isPairedRoom: boolean;
|
||||
memoryBriefing?: string;
|
||||
}): void {
|
||||
const openaiKey =
|
||||
args.envVars.CODEX_OPENAI_API_KEY ||
|
||||
@@ -213,6 +214,7 @@ function prepareCodexSessionEnvironment(args: {
|
||||
args.isPairedRoom
|
||||
? readPairedRoomPrompt('codex', args.projectRoot)
|
||||
: undefined,
|
||||
args.memoryBriefing,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n---\n\n')
|
||||
@@ -298,6 +300,7 @@ export function prepareGroupEnvironment(
|
||||
isMain: boolean,
|
||||
chatJid: string,
|
||||
options?: {
|
||||
memoryBriefing?: string;
|
||||
runtimeTaskId?: string;
|
||||
useTaskScopedSession?: boolean;
|
||||
},
|
||||
@@ -352,6 +355,7 @@ export function prepareGroupEnvironment(
|
||||
claudePlatformPrompt,
|
||||
claudePairedRoomPrompt,
|
||||
globalClaudeMemory,
|
||||
options?.memoryBriefing,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n---\n\n')
|
||||
@@ -409,6 +413,7 @@ export function prepareGroupEnvironment(
|
||||
chatJid,
|
||||
isMain,
|
||||
isPairedRoom,
|
||||
memoryBriefing: options?.memoryBriefing,
|
||||
});
|
||||
} else {
|
||||
prepareClaudeEnvironment({ env, envVars, group });
|
||||
|
||||
@@ -27,6 +27,7 @@ const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
||||
export interface AgentInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
memoryBriefing?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
runId?: string;
|
||||
@@ -63,6 +64,7 @@ export async function runAgentProcess(
|
||||
input.isMain,
|
||||
input.chatJid,
|
||||
{
|
||||
memoryBriefing: input.memoryBriefing,
|
||||
runtimeTaskId: input.runtimeTaskId,
|
||||
useTaskScopedSession: input.useTaskScopedSession,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
|
||||
@@ -7,8 +10,24 @@ export interface ClaudeUsageData {
|
||||
seven_day?: { utilization: number; resets_at: string };
|
||||
}
|
||||
|
||||
export interface ClaudeAccountProfile {
|
||||
index: number;
|
||||
planType: string;
|
||||
}
|
||||
|
||||
export interface ClaudeAccountUsage {
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isRateLimited: boolean;
|
||||
usage: ClaudeUsageData | null;
|
||||
}
|
||||
|
||||
const CLAUDE_EXPECT_TIMEOUT_MS = 25000;
|
||||
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
||||
const HOST_CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
||||
const CLAUDE_ACCOUNTS_DIR = path.join(os.homedir(), '.claude-accounts');
|
||||
|
||||
let cachedProfiles: ClaudeAccountProfile[] = [];
|
||||
|
||||
const EXPECT_PROGRAM = `
|
||||
set timeout 20
|
||||
@@ -114,8 +133,59 @@ export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null {
|
||||
};
|
||||
}
|
||||
|
||||
function listClaudeConfigDirs(): string[] {
|
||||
if (!fs.existsSync(CLAUDE_ACCOUNTS_DIR)) {
|
||||
return [HOST_CLAUDE_DIR];
|
||||
}
|
||||
|
||||
const dirs = fs
|
||||
.readdirSync(CLAUDE_ACCOUNTS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
|
||||
.sort((a, b) => Number(a.name) - Number(b.name))
|
||||
.map((entry) => path.join(CLAUDE_ACCOUNTS_DIR, entry.name));
|
||||
|
||||
return dirs.length > 0 ? dirs : [HOST_CLAUDE_DIR];
|
||||
}
|
||||
|
||||
function inferClaudePlanType(configDir: string): string {
|
||||
const credentialsPath = path.join(configDir, '.credentials.json');
|
||||
return fs.existsSync(credentialsPath) ? 'OAuth' : 'Default';
|
||||
}
|
||||
|
||||
function ensureProfiles(): ClaudeAccountProfile[] {
|
||||
const profiles = listClaudeConfigDirs().map((configDir, index) => ({
|
||||
index,
|
||||
planType: inferClaudePlanType(configDir),
|
||||
}));
|
||||
cachedProfiles = profiles;
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function readCredentialsFile(configDir: string): string | null {
|
||||
try {
|
||||
const credentialsPath = path.join(configDir, '.credentials.json');
|
||||
if (!fs.existsSync(credentialsPath)) return null;
|
||||
return fs.readFileSync(credentialsPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectActiveClaudeIndex(configDirs: string[]): number {
|
||||
if (configDirs.length <= 1) return 0;
|
||||
|
||||
const hostCredentials = readCredentialsFile(HOST_CLAUDE_DIR);
|
||||
if (!hostCredentials) return 0;
|
||||
|
||||
const matchIndex = configDirs.findIndex(
|
||||
(configDir) => readCredentialsFile(configDir) === hostCredentials,
|
||||
);
|
||||
return matchIndex >= 0 ? matchIndex : 0;
|
||||
}
|
||||
|
||||
export async function fetchClaudeUsageViaCli(
|
||||
binary = 'claude',
|
||||
configDir?: string,
|
||||
): Promise<ClaudeUsageData | null> {
|
||||
return new Promise((resolve) => {
|
||||
let output = '';
|
||||
@@ -135,6 +205,7 @@ export async function fetchClaudeUsageViaCli(
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
CLAUDE_BINARY: binary,
|
||||
...(configDir ? { CLAUDE_CONFIG_DIR: configDir } : {}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -181,3 +252,32 @@ export async function fetchClaudeUsageViaCli(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAllClaudeProfiles(): Promise<ClaudeAccountProfile[]> {
|
||||
return ensureProfiles();
|
||||
}
|
||||
|
||||
export function getClaudeProfile(
|
||||
index: number,
|
||||
): ClaudeAccountProfile | undefined {
|
||||
return (cachedProfiles.length > 0 ? cachedProfiles : ensureProfiles()).find(
|
||||
(profile) => profile.index === index,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
|
||||
const configDirs = listClaudeConfigDirs();
|
||||
const profiles = ensureProfiles();
|
||||
const activeIndex = detectActiveClaudeIndex(configDirs);
|
||||
|
||||
const usages = await Promise.all(
|
||||
configDirs.map((configDir) => fetchClaudeUsageViaCli('claude', configDir)),
|
||||
);
|
||||
|
||||
return profiles.map((profile, index) => ({
|
||||
index: profile.index,
|
||||
isActive: index === activeIndex,
|
||||
isRateLimited: false,
|
||||
usage: usages[index] || null,
|
||||
}));
|
||||
}
|
||||
|
||||
166
src/codex-token-rotation.ts
Normal file
166
src/codex-token-rotation.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export interface CodexAccountState {
|
||||
index: number;
|
||||
planType: string;
|
||||
isActive: boolean;
|
||||
isRateLimited: boolean;
|
||||
cachedUsagePct?: number;
|
||||
resetAt?: string;
|
||||
cachedUsageD7Pct?: number;
|
||||
resetD7At?: string;
|
||||
}
|
||||
|
||||
const HOST_CODEX_DIR = path.join(os.homedir(), '.codex');
|
||||
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
|
||||
|
||||
let activeIndexCache: number | null = null;
|
||||
const rateLimitedAccounts = new Set<number>();
|
||||
const usageCache = new Map<
|
||||
number,
|
||||
{
|
||||
cachedUsagePct?: number;
|
||||
resetAt?: string;
|
||||
cachedUsageD7Pct?: number;
|
||||
resetD7At?: string;
|
||||
}
|
||||
>();
|
||||
|
||||
function listCodexAccountDirs(): string[] {
|
||||
if (!fs.existsSync(CODEX_ACCOUNTS_DIR)) return [];
|
||||
|
||||
return fs
|
||||
.readdirSync(CODEX_ACCOUNTS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
|
||||
.sort((a, b) => Number(a.name) - Number(b.name))
|
||||
.map((entry) => path.join(CODEX_ACCOUNTS_DIR, entry.name));
|
||||
}
|
||||
|
||||
function readTextIfExists(filePath: string): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inferPlanType(accountDir: string): string {
|
||||
try {
|
||||
const authPath = path.join(accountDir, 'auth.json');
|
||||
if (!fs.existsSync(authPath)) return 'API';
|
||||
const raw = JSON.parse(fs.readFileSync(authPath, 'utf8')) as {
|
||||
auth_mode?: string;
|
||||
};
|
||||
if (!raw.auth_mode) return 'API';
|
||||
return raw.auth_mode
|
||||
.split(/[_-]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
} catch {
|
||||
return 'API';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActiveIndex(accountDirs: string[]): number {
|
||||
if (accountDirs.length <= 1) return 0;
|
||||
if (activeIndexCache !== null && activeIndexCache < accountDirs.length) {
|
||||
return activeIndexCache;
|
||||
}
|
||||
|
||||
const hostAuth = readTextIfExists(path.join(HOST_CODEX_DIR, 'auth.json'));
|
||||
if (hostAuth) {
|
||||
const matchIndex = accountDirs.findIndex(
|
||||
(accountDir) => readTextIfExists(path.join(accountDir, 'auth.json')) === hostAuth,
|
||||
);
|
||||
if (matchIndex >= 0) {
|
||||
activeIndexCache = matchIndex;
|
||||
return matchIndex;
|
||||
}
|
||||
}
|
||||
|
||||
activeIndexCache = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function copyIfExists(src: string, dst: string): void {
|
||||
if (!fs.existsSync(src)) return;
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
|
||||
function syncHostCodexAccount(accountDir: string): void {
|
||||
copyIfExists(path.join(accountDir, 'auth.json'), path.join(HOST_CODEX_DIR, 'auth.json'));
|
||||
}
|
||||
|
||||
export function getAllCodexAccounts(): CodexAccountState[] {
|
||||
const accountDirs = listCodexAccountDirs();
|
||||
if (accountDirs.length === 0) {
|
||||
return [
|
||||
{
|
||||
index: 0,
|
||||
planType: 'API',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const activeIndex = resolveActiveIndex(accountDirs);
|
||||
return accountDirs.map((accountDir, index) => ({
|
||||
index,
|
||||
planType: inferPlanType(accountDir),
|
||||
isActive: index === activeIndex,
|
||||
isRateLimited: rateLimitedAccounts.has(index),
|
||||
...usageCache.get(index),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getCodexAccountCount(): number {
|
||||
return getAllCodexAccounts().length;
|
||||
}
|
||||
|
||||
export function rotateCodexToken(_reason?: string): boolean {
|
||||
const accountDirs = listCodexAccountDirs();
|
||||
if (accountDirs.length <= 1) return false;
|
||||
|
||||
const activeIndex = resolveActiveIndex(accountDirs);
|
||||
rateLimitedAccounts.add(activeIndex);
|
||||
|
||||
const candidates = accountDirs.map((_, index) => index);
|
||||
const nextIndex =
|
||||
candidates.find(
|
||||
(index) => index !== activeIndex && !rateLimitedAccounts.has(index),
|
||||
) ??
|
||||
candidates.find((index) => index !== activeIndex);
|
||||
|
||||
if (nextIndex === undefined) return false;
|
||||
|
||||
syncHostCodexAccount(accountDirs[nextIndex]);
|
||||
activeIndexCache = nextIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markCodexTokenHealthy(): void {
|
||||
const accountDirs = listCodexAccountDirs();
|
||||
const activeIndex = resolveActiveIndex(accountDirs);
|
||||
rateLimitedAccounts.delete(activeIndex);
|
||||
}
|
||||
|
||||
export function updateCodexAccountUsage(
|
||||
pct: number,
|
||||
resetAt: string | undefined,
|
||||
index: number,
|
||||
d7Pct?: number,
|
||||
resetD7At?: string,
|
||||
): void {
|
||||
usageCache.set(index, {
|
||||
cachedUsagePct: pct,
|
||||
resetAt,
|
||||
cachedUsageD7Pct: d7Pct,
|
||||
resetD7At,
|
||||
});
|
||||
}
|
||||
55
src/memento-client.test.ts
Normal file
55
src/memento-client.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
223
src/memento-client.ts
Normal file
223
src/memento-client.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
138
src/message-agent-executor.test.ts
Normal file
138
src/message-agent-executor.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./agent-runner.js', () => ({
|
||||
runAgentProcess: vi.fn(),
|
||||
writeGroupsSnapshot: vi.fn(),
|
||||
writeTasksSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./available-groups.js', () => ({
|
||||
listAvailableGroups: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||
}));
|
||||
|
||||
vi.mock('./db.js', () => ({
|
||||
getAllTasks: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./provider-fallback.js', () => ({
|
||||
detectFallbackTrigger: vi.fn(() => ({
|
||||
shouldFallback: false,
|
||||
reason: '',
|
||||
})),
|
||||
getActiveProvider: vi.fn(() => 'claude'),
|
||||
getFallbackEnvOverrides: vi.fn(() => ({})),
|
||||
getFallbackProviderName: vi.fn(() => 'fallback'),
|
||||
hasGroupProviderOverride: vi.fn(() => false),
|
||||
isFallbackEnabled: vi.fn(() => false),
|
||||
markPrimaryCooldown: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./session-recovery.js', () => ({
|
||||
shouldResetSessionOnAgentFailure: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('./memento-client.js', () => ({
|
||||
buildRoomMemoryBriefing: vi.fn(),
|
||||
}));
|
||||
|
||||
import { runAgentProcess } from './agent-runner.js';
|
||||
import { buildRoomMemoryBriefing } from './memento-client.js';
|
||||
import { runAgentForGroup } from './message-agent-executor.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
const testGroup: RegisteredGroup = {
|
||||
name: 'Test Group',
|
||||
folder: 'test-group',
|
||||
trigger: '@Andy',
|
||||
added_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const deps = {
|
||||
assistantName: 'Andy',
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
},
|
||||
getRegisteredGroups: vi.fn(() => ({})),
|
||||
getSessions: vi.fn(() => ({})),
|
||||
persistSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
};
|
||||
|
||||
describe('runAgentForGroup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(runAgentProcess).mockResolvedValue({
|
||||
status: 'success',
|
||||
result: 'ok',
|
||||
newSessionId: 'session-123',
|
||||
});
|
||||
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(
|
||||
'## Shared Room Memory\n- remembered context',
|
||||
);
|
||||
});
|
||||
|
||||
it('injects a room memory briefing when starting a fresh session', async () => {
|
||||
deps.getSessions.mockReturnValue({});
|
||||
|
||||
const result = await runAgentForGroup(deps, {
|
||||
group: testGroup,
|
||||
prompt: 'hello',
|
||||
chatJid: 'group@test',
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(buildRoomMemoryBriefing).toHaveBeenCalledWith({
|
||||
groupFolder: 'test-group',
|
||||
groupName: 'Test Group',
|
||||
});
|
||||
expect(runAgentProcess).toHaveBeenCalledWith(
|
||||
testGroup,
|
||||
expect.objectContaining({
|
||||
prompt: 'hello',
|
||||
sessionId: undefined,
|
||||
memoryBriefing: '## Shared Room Memory\n- remembered context',
|
||||
}),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the room memory briefing for existing sessions', async () => {
|
||||
deps.getSessions.mockReturnValue({ 'test-group': 'session-existing' });
|
||||
|
||||
const result = await runAgentForGroup(deps, {
|
||||
group: testGroup,
|
||||
prompt: 'hello again',
|
||||
chatJid: 'group@test',
|
||||
runId: 'run-2',
|
||||
});
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(buildRoomMemoryBriefing).not.toHaveBeenCalled();
|
||||
expect(runAgentProcess).toHaveBeenCalledWith(
|
||||
testGroup,
|
||||
expect.objectContaining({
|
||||
prompt: 'hello again',
|
||||
sessionId: 'session-existing',
|
||||
memoryBriefing: undefined,
|
||||
}),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -1957,6 +1957,86 @@ describe('createMessageRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('retries with the fallback provider when Claude returns only a usage exhaustion banner', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('claude-code');
|
||||
const channel = makeChannel(chatJid);
|
||||
|
||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: chatJid,
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content: 'hello',
|
||||
timestamp: '2026-03-24T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(agentRunner.runAgentProcess)
|
||||
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'final',
|
||||
result: "You're out of extra usage · resets 4am (Asia/Seoul)",
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: null,
|
||||
};
|
||||
})
|
||||
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'final',
|
||||
result: 'usage fallback 응답입니다.',
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: null,
|
||||
};
|
||||
});
|
||||
|
||||
const runtime = createMessageRuntime({
|
||||
assistantName: 'Andy',
|
||||
idleTimeout: 1_000,
|
||||
pollInterval: 1_000,
|
||||
timezone: 'UTC',
|
||||
triggerPattern: /^@Andy\b/i,
|
||||
channels: [channel],
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
closeStdin: vi.fn(),
|
||||
notifyIdle: vi.fn(),
|
||||
} as any,
|
||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||
getSessions: () => ({}),
|
||||
getLastTimestamp: () => '',
|
||||
setLastTimestamp: vi.fn(),
|
||||
getLastAgentTimestamps: () => ({}),
|
||||
saveState: vi.fn(),
|
||||
persistSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await runtime.processGroupMessages(chatJid, {
|
||||
runId: 'run-fallback-usage-exhausted',
|
||||
reason: 'messages',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
|
||||
expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith(
|
||||
'usage-exhausted',
|
||||
undefined,
|
||||
);
|
||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||
chatJid,
|
||||
'usage fallback 응답입니다.',
|
||||
);
|
||||
});
|
||||
|
||||
it('retries with the fallback provider when Claude ends with success-null-result before any output', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('claude-code');
|
||||
|
||||
62
src/token-rotation.ts
Normal file
62
src/token-rotation.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const HOST_CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
||||
const CLAUDE_ACCOUNTS_DIR = path.join(os.homedir(), '.claude-accounts');
|
||||
|
||||
let activeIndex = 0;
|
||||
const rateLimitedAccounts = new Set<number>();
|
||||
|
||||
function listClaudeAccounts(): string[] {
|
||||
if (!fs.existsSync(CLAUDE_ACCOUNTS_DIR)) return [];
|
||||
|
||||
return fs
|
||||
.readdirSync(CLAUDE_ACCOUNTS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
|
||||
.sort((a, b) => Number(a.name) - Number(b.name))
|
||||
.map((entry) => path.join(CLAUDE_ACCOUNTS_DIR, entry.name));
|
||||
}
|
||||
|
||||
function copyIfExists(src: string, dst: string): void {
|
||||
if (!fs.existsSync(src)) return;
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
|
||||
function syncHostClaudeAccount(accountDir: string): void {
|
||||
copyIfExists(
|
||||
path.join(accountDir, '.credentials.json'),
|
||||
path.join(HOST_CLAUDE_DIR, '.credentials.json'),
|
||||
);
|
||||
copyIfExists(path.join(accountDir, 'settings.json'), path.join(HOST_CLAUDE_DIR, 'settings.json'));
|
||||
}
|
||||
|
||||
export function getTokenCount(): number {
|
||||
const accounts = listClaudeAccounts();
|
||||
return accounts.length > 0 ? accounts.length : 1;
|
||||
}
|
||||
|
||||
export function rotateToken(_reason?: string): boolean {
|
||||
const accounts = listClaudeAccounts();
|
||||
if (accounts.length <= 1) return false;
|
||||
|
||||
rateLimitedAccounts.add(activeIndex);
|
||||
|
||||
const candidates = accounts.map((_, index) => index);
|
||||
const nextIndex =
|
||||
candidates.find(
|
||||
(index) => index !== activeIndex && !rateLimitedAccounts.has(index),
|
||||
) ??
|
||||
candidates.find((index) => index !== activeIndex);
|
||||
|
||||
if (nextIndex === undefined) return false;
|
||||
|
||||
syncHostClaudeAccount(accounts[nextIndex]);
|
||||
activeIndex = nextIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markTokenHealthy(): void {
|
||||
rateLimitedAccounts.delete(activeIndex);
|
||||
}
|
||||
@@ -5,9 +5,15 @@ import path from 'path';
|
||||
|
||||
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
|
||||
import {
|
||||
fetchClaudeUsageViaCli,
|
||||
type ClaudeUsageData,
|
||||
fetchAllClaudeUsage,
|
||||
fetchAllClaudeProfiles,
|
||||
getClaudeProfile,
|
||||
type ClaudeAccountUsage,
|
||||
} from './claude-usage.js';
|
||||
import {
|
||||
getAllCodexAccounts,
|
||||
updateCodexAccountUsage,
|
||||
} from './codex-token-rotation.js';
|
||||
import {
|
||||
composeDashboardContent,
|
||||
formatElapsed,
|
||||
@@ -16,7 +22,6 @@ import {
|
||||
renderCategorizedRoomSections,
|
||||
} from './dashboard-render.js';
|
||||
import { getAllChats, updateRegisteredGroupName } from './db.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import type { GroupQueue } from './group-queue.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
@@ -61,11 +66,8 @@ const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
|
||||
|
||||
let statusMessageId: string | null = null;
|
||||
let cachedUsageContent = '';
|
||||
let cachedClaudeUsageData: ClaudeUsageData | null = null;
|
||||
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
|
||||
let usageUpdateInProgress = false;
|
||||
let usageApiBackoffUntil = 0;
|
||||
let usageApi429Streak = 0;
|
||||
let usageApiPollingDisabled = false;
|
||||
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||
let channelMetaLastRefresh = 0;
|
||||
|
||||
@@ -85,6 +87,25 @@ function formatResetKST(value: string | number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatResetRemaining(value: string | number): string {
|
||||
try {
|
||||
const date =
|
||||
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
if (diffMs <= 0) return ' reset';
|
||||
const hours = Math.floor(diffMs / 3_600_000);
|
||||
const minutes = Math.floor((diffMs % 3_600_000) / 60_000);
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
const remH = hours % 24;
|
||||
return `${String(days).padStart(2)}d ${String(remH).padStart(2)}h`;
|
||||
}
|
||||
return `${String(hours).padStart(2)}h ${String(minutes).padStart(2)}m`;
|
||||
} catch {
|
||||
return String(value).padStart(6);
|
||||
}
|
||||
}
|
||||
|
||||
function findDiscordChannel(channels: Channel[]): Channel | undefined {
|
||||
return channels.find(
|
||||
(channel) => channel.name.startsWith('discord') && channel.isConnected(),
|
||||
@@ -108,22 +129,6 @@ export async function purgeDashboardChannel(
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(retryAfter: string | null): number | null {
|
||||
if (!retryAfter) return null;
|
||||
|
||||
const seconds = Number(retryAfter);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
const absolute = Date.parse(retryAfter);
|
||||
if (!Number.isNaN(absolute)) {
|
||||
return Math.max(0, absolute - Date.now());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshChannelMeta(
|
||||
opts: UnifiedDashboardOptions,
|
||||
): Promise<void> {
|
||||
@@ -339,86 +344,9 @@ function buildStatusContent(): string {
|
||||
return `${header}\n\n${sections}`;
|
||||
}
|
||||
|
||||
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||
if (usageApiPollingDisabled) {
|
||||
logger.debug('Skipping usage API call (polling disabled for this process)');
|
||||
return null;
|
||||
}
|
||||
if (Date.now() < usageApiBackoffUntil) {
|
||||
logger.debug('Skipping usage API call (backoff active)');
|
||||
return null;
|
||||
}
|
||||
|
||||
const cliUsage = await fetchClaudeUsageViaCli();
|
||||
if (cliUsage) {
|
||||
usageApi429Streak = 0;
|
||||
return cliUsage;
|
||||
}
|
||||
|
||||
try {
|
||||
const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']);
|
||||
let token =
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
envToken.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
'';
|
||||
if (!token) {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const credsPath = path.join(configDir, '.credentials.json');
|
||||
if (!fs.existsSync(credsPath)) return null;
|
||||
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8'));
|
||||
token = creds?.claudeAiOauth?.accessToken || '';
|
||||
}
|
||||
if (!token) return null;
|
||||
|
||||
const res = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'anthropic-beta': 'oauth-2025-04-20',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
if (res.status === 429) {
|
||||
const retryAfter = res.headers.get('retry-after');
|
||||
const retryAfterMs = parseRetryAfterMs(retryAfter);
|
||||
const backoffMs = Math.max(600_000, retryAfterMs ?? 0);
|
||||
usageApi429Streak += 1;
|
||||
usageApiBackoffUntil = Date.now() + backoffMs;
|
||||
if (usageApi429Streak >= 3) {
|
||||
usageApiPollingDisabled = true;
|
||||
}
|
||||
logger.warn(
|
||||
{
|
||||
status: 429,
|
||||
retryAfter,
|
||||
retryAfterMs,
|
||||
backoffMs,
|
||||
consecutive429s: usageApi429Streak,
|
||||
pollingDisabled: usageApiPollingDisabled,
|
||||
body: body.slice(0, 200),
|
||||
},
|
||||
usageApiPollingDisabled
|
||||
? 'Usage API rate limited repeatedly (429), disabling usage polling for this process'
|
||||
: 'Usage API rate limited (429), backing off',
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ status: res.status, body: body.slice(0, 200) },
|
||||
'Usage API returned non-OK status',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
usageApi429Streak = 0;
|
||||
return (await res.json()) as ClaudeUsageData;
|
||||
} catch (err) {
|
||||
logger.debug({ err }, 'Usage API fetch failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
|
||||
async function fetchCodexUsage(
|
||||
codexHomeOverride?: string,
|
||||
): Promise<CodexRateLimit[] | null> {
|
||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
||||
|
||||
@@ -441,17 +369,22 @@ async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
|
||||
|
||||
const timer = setTimeout(() => finish(null), 20_000);
|
||||
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
PATH: [
|
||||
path.dirname(process.execPath),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
process.env.PATH || '',
|
||||
].join(':'),
|
||||
};
|
||||
if (codexHomeOverride) {
|
||||
spawnEnv.CODEX_HOME = codexHomeOverride;
|
||||
}
|
||||
|
||||
try {
|
||||
proc = spawn(codexBin, ['app-server'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
PATH: [
|
||||
path.dirname(process.execPath),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
process.env.PATH || '',
|
||||
].join(':'),
|
||||
},
|
||||
env: spawnEnv,
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
@@ -509,32 +442,76 @@ async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
|
||||
});
|
||||
}
|
||||
|
||||
async function buildUsageContent(): Promise<string> {
|
||||
const activeSnapshots = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS);
|
||||
const hasActiveClaudeWork = activeSnapshots.some(
|
||||
(snapshot) =>
|
||||
snapshot.agentType === 'claude-code' &&
|
||||
snapshot.entries.some(
|
||||
(entry) => entry.status === 'processing' || entry.status === 'waiting',
|
||||
),
|
||||
type UsageRow = {
|
||||
name: string;
|
||||
h5pct: number;
|
||||
h5reset: string;
|
||||
d7pct: number;
|
||||
d7reset: string;
|
||||
};
|
||||
|
||||
export function mergeClaudeDashboardAccounts(
|
||||
liveAccounts: ClaudeAccountUsage[] | null | undefined,
|
||||
cachedAccounts: ClaudeAccountUsage[],
|
||||
): ClaudeAccountUsage[] {
|
||||
if (!liveAccounts) return cachedAccounts;
|
||||
|
||||
const cachedByIndex = new Map(
|
||||
cachedAccounts.map((account) => [account.index, account]),
|
||||
);
|
||||
|
||||
return liveAccounts.map((account) => ({
|
||||
...account,
|
||||
usage: account.usage || cachedByIndex.get(account.index)?.usage || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildClaudeUsageRows(
|
||||
claudeAccounts: ClaudeAccountUsage[],
|
||||
): UsageRow[] {
|
||||
const isMultiAccount = claudeAccounts.length > 1;
|
||||
|
||||
return claudeAccounts.map((account) => {
|
||||
const usage = account.usage;
|
||||
const h5 = usage?.five_hour;
|
||||
const d7 = usage?.seven_day;
|
||||
const profile = getClaudeProfile(account.index);
|
||||
const planSuffix = profile ? ` ${profile.planType}` : '';
|
||||
const label = isMultiAccount
|
||||
? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`
|
||||
: `Claude${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`;
|
||||
|
||||
return {
|
||||
name: label,
|
||||
h5pct: h5
|
||||
? h5.utilization > 1
|
||||
? Math.round(h5.utilization)
|
||||
: Math.round(h5.utilization * 100)
|
||||
: -1,
|
||||
h5reset: h5 ? formatResetRemaining(h5.resets_at) : '',
|
||||
d7pct: d7
|
||||
? d7.utilization > 1
|
||||
? Math.round(d7.utilization)
|
||||
: Math.round(d7.utilization * 100)
|
||||
: -1,
|
||||
d7reset: d7 ? formatResetRemaining(d7.resets_at) : '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function buildUsageContent(): Promise<string> {
|
||||
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
||||
let liveClaudeAccounts: ClaudeAccountUsage[] | null = null;
|
||||
|
||||
const [liveClaudeUsage, codexUsage] = await Promise.all([
|
||||
shouldFetchClaudeUsage && !hasActiveClaudeWork
|
||||
? fetchClaudeUsage()
|
||||
: Promise.resolve(null),
|
||||
fetchCodexUsage(),
|
||||
]);
|
||||
|
||||
const claudeUsage = shouldFetchClaudeUsage
|
||||
? liveClaudeUsage || cachedClaudeUsageData
|
||||
: null;
|
||||
const claudeUsageIsCached =
|
||||
shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData;
|
||||
if (shouldFetchClaudeUsage && liveClaudeUsage) {
|
||||
cachedClaudeUsageData = liveClaudeUsage;
|
||||
const codexUsagePromise = fetchCodexUsage();
|
||||
if (shouldFetchClaudeUsage) {
|
||||
try {
|
||||
liveClaudeAccounts = await fetchAllClaudeUsage();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to fetch Claude usage for dashboard');
|
||||
}
|
||||
}
|
||||
const codexUsage = await codexUsagePromise;
|
||||
|
||||
const lines: string[] = ['📊 *사용량*'];
|
||||
const bar = (pct: number) => {
|
||||
@@ -542,36 +519,55 @@ async function buildUsageContent(): Promise<string> {
|
||||
return '█'.repeat(filled) + '░'.repeat(10 - filled);
|
||||
};
|
||||
|
||||
type UsageRow = {
|
||||
name: string;
|
||||
h5pct: number;
|
||||
h5reset: string;
|
||||
d7pct: number;
|
||||
d7reset: string;
|
||||
};
|
||||
const rows: UsageRow[] = [];
|
||||
|
||||
if (claudeUsage) {
|
||||
const h5 = claudeUsage.five_hour;
|
||||
const d7 = claudeUsage.seven_day;
|
||||
rows.push({
|
||||
name: claudeUsageIsCached ? 'Claude*' : 'Claude',
|
||||
h5pct: h5
|
||||
? h5.utilization > 1
|
||||
? Math.round(h5.utilization)
|
||||
: Math.round(h5.utilization * 100)
|
||||
: -1,
|
||||
h5reset: h5 ? formatResetKST(h5.resets_at) : '',
|
||||
d7pct: d7
|
||||
? d7.utilization > 1
|
||||
? Math.round(d7.utilization)
|
||||
: Math.round(d7.utilization * 100)
|
||||
: -1,
|
||||
d7reset: d7 ? formatResetKST(d7.resets_at) : '',
|
||||
});
|
||||
if (shouldFetchClaudeUsage) {
|
||||
cachedClaudeAccounts = mergeClaudeDashboardAccounts(
|
||||
liveClaudeAccounts,
|
||||
cachedClaudeAccounts,
|
||||
);
|
||||
rows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
|
||||
}
|
||||
|
||||
if (codexUsage && Array.isArray(codexUsage)) {
|
||||
const codexAccounts = getAllCodexAccounts();
|
||||
if (codexAccounts.length > 1) {
|
||||
// Multi-account: show each account with plan + status
|
||||
for (const acct of codexAccounts) {
|
||||
const icon = acct.isActive ? '*' : acct.isRateLimited ? '!' : ' ';
|
||||
const label = `Codex${acct.index + 1}${icon}`;
|
||||
if (acct.isActive && codexUsage && Array.isArray(codexUsage)) {
|
||||
const relevant = codexUsage.filter(
|
||||
(limit) =>
|
||||
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
||||
);
|
||||
const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1);
|
||||
for (const limit of display) {
|
||||
rows.push({
|
||||
name: `${label} ${acct.planType}`,
|
||||
h5pct: Math.round(limit.primary.usedPercent),
|
||||
h5reset: formatResetRemaining(limit.primary.resetsAt),
|
||||
d7pct: Math.round(limit.secondary.usedPercent),
|
||||
d7reset: formatResetRemaining(limit.secondary.resetsAt),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Show cached usage from last scan
|
||||
const pct = acct.cachedUsagePct != null ? acct.cachedUsagePct : -1;
|
||||
const d7pct =
|
||||
acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1;
|
||||
const reset = acct.resetAt || '';
|
||||
const d7reset = acct.resetD7At || '';
|
||||
rows.push({
|
||||
name: `${label} ${acct.planType}`,
|
||||
h5pct: pct,
|
||||
h5reset: reset,
|
||||
d7pct,
|
||||
d7reset,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (codexUsage && Array.isArray(codexUsage)) {
|
||||
// Single account: existing behavior
|
||||
const relevant = codexUsage.filter(
|
||||
(limit) =>
|
||||
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
||||
@@ -589,8 +585,15 @@ async function buildUsageContent(): Promise<string> {
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
// Emoji characters take 2 columns in monospace — count visual width
|
||||
const visualWidth = (s: string) =>
|
||||
[...s].reduce((w, c) => w + (c.codePointAt(0)! > 0x7f ? 2 : 1), 0);
|
||||
const maxNameWidth =
|
||||
Math.max(8, ...rows.map((r) => visualWidth(r.name))) + 1;
|
||||
const padName = (s: string) =>
|
||||
s + ' '.repeat(maxNameWidth - visualWidth(s));
|
||||
lines.push('```');
|
||||
lines.push(' 5-Hour 7-Day');
|
||||
lines.push(`${' '.repeat(maxNameWidth)}5-Hour 7-Day`);
|
||||
for (const row of rows) {
|
||||
const h5 =
|
||||
row.h5pct >= 0
|
||||
@@ -600,21 +603,17 @@ async function buildUsageContent(): Promise<string> {
|
||||
row.d7pct >= 0
|
||||
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
|
||||
: ' — ';
|
||||
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
|
||||
const reset =
|
||||
row.h5reset || row.d7reset
|
||||
? ` ${row.h5reset || ''}${row.d7reset ? ` / ${row.d7reset}` : ''}`
|
||||
: '';
|
||||
lines.push(`${padName(row.name)}${h5} ${d7}${reset}`);
|
||||
}
|
||||
lines.push('```');
|
||||
} else {
|
||||
lines.push('_조회 불가_');
|
||||
}
|
||||
|
||||
if (shouldFetchClaudeUsage && usageApiPollingDisabled) {
|
||||
lines.push(
|
||||
'_* Claude 사용량 조회는 반복된 429로 이번 프로세스에서 일시 중지_',
|
||||
);
|
||||
}
|
||||
if (claudeUsageIsCached) {
|
||||
lines.push('_* Claude 사용량은 작업 중일 때는 캐시값 유지_');
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('🖥️ *서버*');
|
||||
|
||||
@@ -670,13 +669,80 @@ function buildUnifiedDashboardContent(): string {
|
||||
return composeDashboardContent(sections);
|
||||
}
|
||||
|
||||
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
|
||||
const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour
|
||||
|
||||
/**
|
||||
* Scan ALL Codex accounts by spawning app-server with each auth.
|
||||
* Called on startup and every hour to keep cached usage fresh.
|
||||
*/
|
||||
async function refreshAllCodexAccountUsage(): Promise<void> {
|
||||
const codexAccounts = getAllCodexAccounts();
|
||||
if (codexAccounts.length <= 1) return;
|
||||
|
||||
logger.info(
|
||||
{ accountCount: codexAccounts.length },
|
||||
'Scanning all Codex accounts for usage data',
|
||||
);
|
||||
|
||||
for (const acct of codexAccounts) {
|
||||
const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(acct.index + 1));
|
||||
if (!fs.existsSync(accountDir)) continue;
|
||||
|
||||
try {
|
||||
const usage = await fetchCodexUsage(accountDir);
|
||||
if (usage && Array.isArray(usage)) {
|
||||
const relevant = usage.filter(
|
||||
(l) => l.primary.usedPercent > 0 || l.secondary.usedPercent > 0,
|
||||
);
|
||||
const display = relevant.length > 0 ? relevant : usage.slice(0, 1);
|
||||
if (usage.length > 0) {
|
||||
// Find max primary (5h) and secondary (7d) across all limits
|
||||
// Always capture reset times from first limit as baseline
|
||||
let maxH5 = 0;
|
||||
let maxD7 = 0;
|
||||
let h5Reset: string | number | undefined = usage[0].primary.resetsAt;
|
||||
let d7Reset: string | number | undefined =
|
||||
usage[0].secondary.resetsAt;
|
||||
for (const limit of usage) {
|
||||
if (limit.primary.usedPercent >= maxH5) {
|
||||
maxH5 = limit.primary.usedPercent;
|
||||
h5Reset = limit.primary.resetsAt;
|
||||
}
|
||||
if (limit.secondary.usedPercent >= maxD7) {
|
||||
maxD7 = limit.secondary.usedPercent;
|
||||
d7Reset = limit.secondary.resetsAt;
|
||||
}
|
||||
}
|
||||
const pct = Math.round(maxH5);
|
||||
const d7Pct = Math.round(maxD7);
|
||||
const resetStr = h5Reset ? formatResetRemaining(h5Reset) : undefined;
|
||||
const resetD7Str = d7Reset
|
||||
? formatResetRemaining(d7Reset)
|
||||
: undefined;
|
||||
updateCodexAccountUsage(pct, resetStr, acct.index, d7Pct, resetD7Str);
|
||||
logger.info(
|
||||
{ account: acct.index + 1, h5: pct, d7: d7Pct, reset: resetStr },
|
||||
`Codex account #${acct.index + 1} usage: 5h=${pct}% 7d=${d7Pct}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err, account: acct.index + 1 },
|
||||
'Failed to fetch usage for Codex account',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsageCache(): Promise<void> {
|
||||
if (usageUpdateInProgress) return;
|
||||
usageUpdateInProgress = true;
|
||||
try {
|
||||
cachedUsageContent = await buildUsageContent();
|
||||
} catch {
|
||||
/* keep previous cache */
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to build usage content');
|
||||
} finally {
|
||||
usageUpdateInProgress = false;
|
||||
}
|
||||
@@ -695,6 +761,7 @@ export async function startUnifiedDashboard(
|
||||
}
|
||||
|
||||
if (isRenderer) {
|
||||
await fetchAllClaudeProfiles();
|
||||
await refreshUsageCache();
|
||||
}
|
||||
|
||||
@@ -709,6 +776,13 @@ export async function startUnifiedDashboard(
|
||||
await refreshChannelMeta(opts);
|
||||
const content = buildUnifiedDashboardContent();
|
||||
if (!content) {
|
||||
logger.warn(
|
||||
{
|
||||
cachedUsageLength: cachedUsageContent.length,
|
||||
statusShowRooms: STATUS_SHOW_ROOMS,
|
||||
},
|
||||
'Dashboard content empty, skipping render',
|
||||
);
|
||||
statusMessageId = null;
|
||||
return;
|
||||
}
|
||||
@@ -720,7 +794,7 @@ export async function startUnifiedDashboard(
|
||||
if (id) statusMessageId = id;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ err }, 'Dashboard update failed');
|
||||
logger.warn({ err }, 'Dashboard update failed');
|
||||
statusMessageId = null;
|
||||
}
|
||||
};
|
||||
@@ -730,6 +804,16 @@ export async function startUnifiedDashboard(
|
||||
|
||||
if (isRenderer) {
|
||||
setInterval(refreshUsageCache, opts.usageUpdateInterval);
|
||||
// Full scan of all Codex accounts on startup + hourly
|
||||
// After scan, refresh dashboard so cached data is visible immediately
|
||||
void refreshAllCodexAccountUsage().then(() => {
|
||||
void refreshUsageCache().then(() => void updateStatus());
|
||||
});
|
||||
setInterval(
|
||||
() =>
|
||||
void refreshAllCodexAccountUsage().then(() => void refreshUsageCache()),
|
||||
CODEX_FULL_SCAN_INTERVAL,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user