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"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"discord.js": "^14.18.0",
|
"discord.js": "^14.18.0",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
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';
|
||||||
|
|
||||||
interface ContainerInput {
|
interface ContainerInput {
|
||||||
@@ -65,11 +67,19 @@ 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';
|
||||||
// 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 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');
|
||||||
@@ -221,6 +231,103 @@ 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 {
|
||||||
|
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.
|
* Archive the full transcript to conversations/ before compaction.
|
||||||
*/
|
*/
|
||||||
@@ -258,6 +365,10 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
|||||||
fs.writeFileSync(filePath, markdown);
|
fs.writeFileSync(filePath, markdown);
|
||||||
|
|
||||||
log(`Archived conversation to ${filePath}`);
|
log(`Archived conversation to ${filePath}`);
|
||||||
|
|
||||||
|
if (summary) {
|
||||||
|
await persistCompactMemory(summary, sessionId);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ function prepareCodexSessionEnvironment(args: {
|
|||||||
chatJid: string;
|
chatJid: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isPairedRoom: boolean;
|
isPairedRoom: boolean;
|
||||||
|
memoryBriefing?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const openaiKey =
|
const openaiKey =
|
||||||
args.envVars.CODEX_OPENAI_API_KEY ||
|
args.envVars.CODEX_OPENAI_API_KEY ||
|
||||||
@@ -213,6 +214,7 @@ function prepareCodexSessionEnvironment(args: {
|
|||||||
args.isPairedRoom
|
args.isPairedRoom
|
||||||
? readPairedRoomPrompt('codex', args.projectRoot)
|
? readPairedRoomPrompt('codex', args.projectRoot)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
args.memoryBriefing,
|
||||||
]
|
]
|
||||||
.filter((value): value is string => Boolean(value))
|
.filter((value): value is string => Boolean(value))
|
||||||
.join('\n\n---\n\n')
|
.join('\n\n---\n\n')
|
||||||
@@ -298,6 +300,7 @@ export function prepareGroupEnvironment(
|
|||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
memoryBriefing?: string;
|
||||||
runtimeTaskId?: string;
|
runtimeTaskId?: string;
|
||||||
useTaskScopedSession?: boolean;
|
useTaskScopedSession?: boolean;
|
||||||
},
|
},
|
||||||
@@ -352,6 +355,7 @@ export function prepareGroupEnvironment(
|
|||||||
claudePlatformPrompt,
|
claudePlatformPrompt,
|
||||||
claudePairedRoomPrompt,
|
claudePairedRoomPrompt,
|
||||||
globalClaudeMemory,
|
globalClaudeMemory,
|
||||||
|
options?.memoryBriefing,
|
||||||
]
|
]
|
||||||
.filter((value): value is string => Boolean(value))
|
.filter((value): value is string => Boolean(value))
|
||||||
.join('\n\n---\n\n')
|
.join('\n\n---\n\n')
|
||||||
@@ -409,6 +413,7 @@ export function prepareGroupEnvironment(
|
|||||||
chatJid,
|
chatJid,
|
||||||
isMain,
|
isMain,
|
||||||
isPairedRoom,
|
isPairedRoom,
|
||||||
|
memoryBriefing: options?.memoryBriefing,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
prepareClaudeEnvironment({ env, envVars, group });
|
prepareClaudeEnvironment({ env, envVars, group });
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
|||||||
export interface AgentInput {
|
export interface AgentInput {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
memoryBriefing?: string;
|
||||||
groupFolder: string;
|
groupFolder: string;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
@@ -63,6 +64,7 @@ export async function runAgentProcess(
|
|||||||
input.isMain,
|
input.isMain,
|
||||||
input.chatJid,
|
input.chatJid,
|
||||||
{
|
{
|
||||||
|
memoryBriefing: input.memoryBriefing,
|
||||||
runtimeTaskId: input.runtimeTaskId,
|
runtimeTaskId: input.runtimeTaskId,
|
||||||
useTaskScopedSession: input.useTaskScopedSession,
|
useTaskScopedSession: input.useTaskScopedSession,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
@@ -7,8 +10,24 @@ export interface ClaudeUsageData {
|
|||||||
seven_day?: { utilization: number; resets_at: string };
|
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 CLAUDE_EXPECT_TIMEOUT_MS = 25000;
|
||||||
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
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 = `
|
const EXPECT_PROGRAM = `
|
||||||
set timeout 20
|
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(
|
export async function fetchClaudeUsageViaCli(
|
||||||
binary = 'claude',
|
binary = 'claude',
|
||||||
|
configDir?: string,
|
||||||
): Promise<ClaudeUsageData | null> {
|
): Promise<ClaudeUsageData | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let output = '';
|
let output = '';
|
||||||
@@ -135,6 +205,7 @@ export async function fetchClaudeUsageViaCli(
|
|||||||
env: {
|
env: {
|
||||||
...(process.env as Record<string, string>),
|
...(process.env as Record<string, string>),
|
||||||
CLAUDE_BINARY: binary,
|
CLAUDE_BINARY: binary,
|
||||||
|
...(configDir ? { CLAUDE_CONFIG_DIR: configDir } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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 { getAllTasks } from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { buildRoomMemoryBriefing } from './memento-client.js';
|
||||||
import {
|
import {
|
||||||
detectFallbackTrigger,
|
detectFallbackTrigger,
|
||||||
getActiveProvider,
|
getActiveProvider,
|
||||||
@@ -21,6 +22,16 @@ import {
|
|||||||
markPrimaryCooldown,
|
markPrimaryCooldown,
|
||||||
} from './provider-fallback.js';
|
} from './provider-fallback.js';
|
||||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.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';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
export interface MessageAgentExecutorDeps {
|
export interface MessageAgentExecutorDeps {
|
||||||
@@ -32,6 +43,14 @@ export interface MessageAgentExecutorDeps {
|
|||||||
clearSession: (groupFolder: string) => void;
|
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(
|
export async function runAgentForGroup(
|
||||||
deps: MessageAgentExecutorDeps,
|
deps: MessageAgentExecutorDeps,
|
||||||
args: {
|
args: {
|
||||||
@@ -48,6 +67,12 @@ export async function runAgentForGroup(
|
|||||||
(group.agentType || 'claude-code') === 'claude-code';
|
(group.agentType || 'claude-code') === 'claude-code';
|
||||||
const sessions = deps.getSessions();
|
const sessions = deps.getSessions();
|
||||||
const sessionId = sessions[group.folder];
|
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');
|
const tasks = getAllTasks(group.agentType || 'claude-code');
|
||||||
writeTasksSnapshot(
|
writeTasksSnapshot(
|
||||||
@@ -86,6 +111,7 @@ export async function runAgentForGroup(
|
|||||||
const agentInput = {
|
const agentInput = {
|
||||||
prompt,
|
prompt,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
memoryBriefing,
|
||||||
groupFolder: group.folder,
|
groupFolder: group.folder,
|
||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
@@ -127,6 +153,20 @@ export async function runAgentForGroup(
|
|||||||
) {
|
) {
|
||||||
resetSessionRequested = true;
|
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) {
|
if (output.result !== null && output.result !== undefined) {
|
||||||
sawOutput = true;
|
sawOutput = true;
|
||||||
} else if (
|
} else if (
|
||||||
@@ -137,7 +177,6 @@ export async function runAgentForGroup(
|
|||||||
sawSuccessNullResultWithoutOutput = true;
|
sawSuccessNullResultWithoutOutput = true;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
provider === 'claude' &&
|
|
||||||
output.status === 'error' &&
|
output.status === 'error' &&
|
||||||
!sawOutput &&
|
!sawOutput &&
|
||||||
!streamedTriggerReason
|
!streamedTriggerReason
|
||||||
@@ -299,6 +338,18 @@ export async function runAgentForGroup(
|
|||||||
}
|
}
|
||||||
: detectFallbackTrigger(errMsg);
|
: detectFallbackTrigger(errMsg);
|
||||||
if (trigger.shouldFallback) {
|
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);
|
return runFallbackAttempt(trigger.reason, trigger.retryAfterMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,6 +383,19 @@ export async function runAgentForGroup(
|
|||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
canFallback &&
|
||||||
|
provider === 'claude' &&
|
||||||
|
!primaryAttempt.sawOutput &&
|
||||||
|
primaryAttempt.streamedTriggerReason &&
|
||||||
|
output.status !== 'error'
|
||||||
|
) {
|
||||||
|
return runFallbackAttempt(
|
||||||
|
primaryAttempt.streamedTriggerReason.reason,
|
||||||
|
primaryAttempt.streamedTriggerReason.retryAfterMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
canFallback &&
|
canFallback &&
|
||||||
provider === 'claude' &&
|
provider === 'claude' &&
|
||||||
@@ -362,10 +426,40 @@ export async function runAgentForGroup(
|
|||||||
}
|
}
|
||||||
: detectFallbackTrigger(output.error);
|
: detectFallbackTrigger(output.error);
|
||||||
if (trigger.shouldFallback) {
|
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);
|
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(
|
logger.error(
|
||||||
{
|
{
|
||||||
group: group.name,
|
group: group.name,
|
||||||
@@ -379,5 +473,29 @@ export async function runAgentForGroup(
|
|||||||
return 'error';
|
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';
|
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 () => {
|
it('retries with the fallback provider when Claude ends with success-null-result before any output', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('claude-code');
|
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 { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
|
||||||
import {
|
import {
|
||||||
fetchClaudeUsageViaCli,
|
fetchAllClaudeUsage,
|
||||||
type ClaudeUsageData,
|
fetchAllClaudeProfiles,
|
||||||
|
getClaudeProfile,
|
||||||
|
type ClaudeAccountUsage,
|
||||||
} from './claude-usage.js';
|
} from './claude-usage.js';
|
||||||
|
import {
|
||||||
|
getAllCodexAccounts,
|
||||||
|
updateCodexAccountUsage,
|
||||||
|
} from './codex-token-rotation.js';
|
||||||
import {
|
import {
|
||||||
composeDashboardContent,
|
composeDashboardContent,
|
||||||
formatElapsed,
|
formatElapsed,
|
||||||
@@ -16,7 +22,6 @@ import {
|
|||||||
renderCategorizedRoomSections,
|
renderCategorizedRoomSections,
|
||||||
} from './dashboard-render.js';
|
} from './dashboard-render.js';
|
||||||
import { getAllChats, updateRegisteredGroupName } from './db.js';
|
import { getAllChats, updateRegisteredGroupName } from './db.js';
|
||||||
import { readEnvFile } from './env.js';
|
|
||||||
import type { GroupQueue } from './group-queue.js';
|
import type { GroupQueue } from './group-queue.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
@@ -61,11 +66,8 @@ const STATUS_SNAPSHOT_MAX_AGE_MS = 60000;
|
|||||||
|
|
||||||
let statusMessageId: string | null = null;
|
let statusMessageId: string | null = null;
|
||||||
let cachedUsageContent = '';
|
let cachedUsageContent = '';
|
||||||
let cachedClaudeUsageData: ClaudeUsageData | null = null;
|
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
|
||||||
let usageUpdateInProgress = false;
|
let usageUpdateInProgress = false;
|
||||||
let usageApiBackoffUntil = 0;
|
|
||||||
let usageApi429Streak = 0;
|
|
||||||
let usageApiPollingDisabled = false;
|
|
||||||
let channelMetaCache = new Map<string, ChannelMeta>();
|
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||||
let channelMetaLastRefresh = 0;
|
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 {
|
function findDiscordChannel(channels: Channel[]): Channel | undefined {
|
||||||
return channels.find(
|
return channels.find(
|
||||||
(channel) => channel.name.startsWith('discord') && channel.isConnected(),
|
(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(
|
async function refreshChannelMeta(
|
||||||
opts: UnifiedDashboardOptions,
|
opts: UnifiedDashboardOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -339,86 +344,9 @@ function buildStatusContent(): string {
|
|||||||
return `${header}\n\n${sections}`;
|
return `${header}\n\n${sections}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
async function fetchCodexUsage(
|
||||||
if (usageApiPollingDisabled) {
|
codexHomeOverride?: string,
|
||||||
logger.debug('Skipping usage API call (polling disabled for this process)');
|
): Promise<CodexRateLimit[] | null> {
|
||||||
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> {
|
|
||||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||||
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : '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 timer = setTimeout(() => finish(null), 20_000);
|
||||||
|
|
||||||
try {
|
const spawnEnv: Record<string, string> = {
|
||||||
proc = spawn(codexBin, ['app-server'], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...(process.env as Record<string, string>),
|
...(process.env as Record<string, string>),
|
||||||
PATH: [
|
PATH: [
|
||||||
path.dirname(process.execPath),
|
path.dirname(process.execPath),
|
||||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||||
process.env.PATH || '',
|
process.env.PATH || '',
|
||||||
].join(':'),
|
].join(':'),
|
||||||
},
|
};
|
||||||
|
if (codexHomeOverride) {
|
||||||
|
spawnEnv.CODEX_HOME = codexHomeOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = spawn(codexBin, ['app-server'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: spawnEnv,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
resolve(null);
|
resolve(null);
|
||||||
@@ -509,39 +442,6 @@ 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',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
|
||||||
|
|
||||||
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 lines: string[] = ['📊 *사용량*'];
|
|
||||||
const bar = (pct: number) => {
|
|
||||||
const filled = Math.round(pct / 10);
|
|
||||||
return '█'.repeat(filled) + '░'.repeat(10 - filled);
|
|
||||||
};
|
|
||||||
|
|
||||||
type UsageRow = {
|
type UsageRow = {
|
||||||
name: string;
|
name: string;
|
||||||
h5pct: number;
|
h5pct: number;
|
||||||
@@ -549,29 +449,125 @@ async function buildUsageContent(): Promise<string> {
|
|||||||
d7pct: number;
|
d7pct: number;
|
||||||
d7reset: string;
|
d7reset: string;
|
||||||
};
|
};
|
||||||
const rows: UsageRow[] = [];
|
|
||||||
|
|
||||||
if (claudeUsage) {
|
export function mergeClaudeDashboardAccounts(
|
||||||
const h5 = claudeUsage.five_hour;
|
liveAccounts: ClaudeAccountUsage[] | null | undefined,
|
||||||
const d7 = claudeUsage.seven_day;
|
cachedAccounts: ClaudeAccountUsage[],
|
||||||
rows.push({
|
): ClaudeAccountUsage[] {
|
||||||
name: claudeUsageIsCached ? 'Claude*' : 'Claude',
|
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
|
h5pct: h5
|
||||||
? h5.utilization > 1
|
? h5.utilization > 1
|
||||||
? Math.round(h5.utilization)
|
? Math.round(h5.utilization)
|
||||||
: Math.round(h5.utilization * 100)
|
: Math.round(h5.utilization * 100)
|
||||||
: -1,
|
: -1,
|
||||||
h5reset: h5 ? formatResetKST(h5.resets_at) : '',
|
h5reset: h5 ? formatResetRemaining(h5.resets_at) : '',
|
||||||
d7pct: d7
|
d7pct: d7
|
||||||
? d7.utilization > 1
|
? d7.utilization > 1
|
||||||
? Math.round(d7.utilization)
|
? Math.round(d7.utilization)
|
||||||
: Math.round(d7.utilization * 100)
|
: Math.round(d7.utilization * 100)
|
||||||
: -1,
|
: -1,
|
||||||
d7reset: d7 ? formatResetKST(d7.resets_at) : '',
|
d7reset: d7 ? formatResetRemaining(d7.resets_at) : '',
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (codexUsage && Array.isArray(codexUsage)) {
|
async function buildUsageContent(): Promise<string> {
|
||||||
|
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
||||||
|
let liveClaudeAccounts: ClaudeAccountUsage[] | null = null;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const filled = Math.round(pct / 10);
|
||||||
|
return '█'.repeat(filled) + '░'.repeat(10 - filled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: UsageRow[] = [];
|
||||||
|
|
||||||
|
if (shouldFetchClaudeUsage) {
|
||||||
|
cachedClaudeAccounts = mergeClaudeDashboardAccounts(
|
||||||
|
liveClaudeAccounts,
|
||||||
|
cachedClaudeAccounts,
|
||||||
|
);
|
||||||
|
rows.push(...buildClaudeUsageRows(cachedClaudeAccounts));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
const relevant = codexUsage.filter(
|
||||||
(limit) =>
|
(limit) =>
|
||||||
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
||||||
@@ -589,8 +585,15 @@ async function buildUsageContent(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rows.length > 0) {
|
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('```');
|
||||||
lines.push(' 5-Hour 7-Day');
|
lines.push(`${' '.repeat(maxNameWidth)}5-Hour 7-Day`);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const h5 =
|
const h5 =
|
||||||
row.h5pct >= 0
|
row.h5pct >= 0
|
||||||
@@ -600,21 +603,17 @@ async function buildUsageContent(): Promise<string> {
|
|||||||
row.d7pct >= 0
|
row.d7pct >= 0
|
||||||
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
|
? `${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('```');
|
lines.push('```');
|
||||||
} else {
|
} else {
|
||||||
lines.push('_조회 불가_');
|
lines.push('_조회 불가_');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldFetchClaudeUsage && usageApiPollingDisabled) {
|
|
||||||
lines.push(
|
|
||||||
'_* Claude 사용량 조회는 반복된 429로 이번 프로세스에서 일시 중지_',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (claudeUsageIsCached) {
|
|
||||||
lines.push('_* Claude 사용량은 작업 중일 때는 캐시값 유지_');
|
|
||||||
}
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('🖥️ *서버*');
|
lines.push('🖥️ *서버*');
|
||||||
|
|
||||||
@@ -670,13 +669,80 @@ function buildUnifiedDashboardContent(): string {
|
|||||||
return composeDashboardContent(sections);
|
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> {
|
async function refreshUsageCache(): Promise<void> {
|
||||||
if (usageUpdateInProgress) return;
|
if (usageUpdateInProgress) return;
|
||||||
usageUpdateInProgress = true;
|
usageUpdateInProgress = true;
|
||||||
try {
|
try {
|
||||||
cachedUsageContent = await buildUsageContent();
|
cachedUsageContent = await buildUsageContent();
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* keep previous cache */
|
logger.warn({ err }, 'Failed to build usage content');
|
||||||
} finally {
|
} finally {
|
||||||
usageUpdateInProgress = false;
|
usageUpdateInProgress = false;
|
||||||
}
|
}
|
||||||
@@ -695,6 +761,7 @@ export async function startUnifiedDashboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRenderer) {
|
if (isRenderer) {
|
||||||
|
await fetchAllClaudeProfiles();
|
||||||
await refreshUsageCache();
|
await refreshUsageCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,6 +776,13 @@ export async function startUnifiedDashboard(
|
|||||||
await refreshChannelMeta(opts);
|
await refreshChannelMeta(opts);
|
||||||
const content = buildUnifiedDashboardContent();
|
const content = buildUnifiedDashboardContent();
|
||||||
if (!content) {
|
if (!content) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
cachedUsageLength: cachedUsageContent.length,
|
||||||
|
statusShowRooms: STATUS_SHOW_ROOMS,
|
||||||
|
},
|
||||||
|
'Dashboard content empty, skipping render',
|
||||||
|
);
|
||||||
statusMessageId = null;
|
statusMessageId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -720,7 +794,7 @@ export async function startUnifiedDashboard(
|
|||||||
if (id) statusMessageId = id;
|
if (id) statusMessageId = id;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug({ err }, 'Dashboard update failed');
|
logger.warn({ err }, 'Dashboard update failed');
|
||||||
statusMessageId = null;
|
statusMessageId = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -730,6 +804,16 @@ export async function startUnifiedDashboard(
|
|||||||
|
|
||||||
if (isRenderer) {
|
if (isRenderer) {
|
||||||
setInterval(refreshUsageCache, opts.usageUpdateInterval);
|
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(
|
logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user