From 3467e245d5297417ceafed92aba4e02c48211808 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 24 Mar 2026 03:48:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20auto=20memory=20pipeline=20=E2=80=94=20?= =?UTF-8?q?host-driven=20Memento=20recall/reflect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 1093 +++++++++++++++++++++++++++- package.json | 1 + runners/agent-runner/src/index.ts | 111 +++ src/agent-runner-environment.ts | 5 + src/agent-runner.ts | 2 + src/claude-usage.ts | 100 +++ src/codex-token-rotation.ts | 166 +++++ src/memento-client.test.ts | 55 ++ src/memento-client.ts | 223 ++++++ src/message-agent-executor.test.ts | 138 ++++ src/message-agent-executor.ts | 120 ++- src/message-runtime.test.ts | 80 ++ src/token-rotation.ts | 62 ++ src/unified-dashboard.ts | 428 ++++++----- 14 files changed, 2406 insertions(+), 178 deletions(-) create mode 100644 src/codex-token-rotation.ts create mode 100644 src/memento-client.test.ts create mode 100644 src/memento-client.ts create mode 100644 src/message-agent-executor.test.ts create mode 100644 src/token-rotation.ts diff --git a/package-lock.json b/package-lock.json index 9bcbe11..1bbfc43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "ejclaw", "version": "1.2.12", "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", "discord.js": "^14.18.0", @@ -662,6 +663,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -690,6 +703,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1291,6 +1344,52 @@ "npm": ">=7.0.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1373,6 +1472,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1397,6 +1520,44 @@ "ieee754": "^1.1.13" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1419,6 +1580,63 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1431,6 +1649,20 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -1440,6 +1672,23 @@ "node": "*" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1464,6 +1713,15 @@ "node": ">=4.0.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1509,6 +1767,35 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1518,6 +1805,24 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1525,6 +1830,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1567,6 +1884,12 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1577,6 +1900,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1596,6 +1949,67 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -1614,6 +2028,22 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1638,6 +2068,45 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1659,6 +2128,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1678,6 +2193,18 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1688,12 +2215,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -1701,6 +2261,26 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -1717,6 +2297,22 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1749,6 +2345,36 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -1788,6 +2414,15 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1804,6 +2439,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -1869,6 +2516,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1896,6 +2598,12 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1921,6 +2629,15 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -1933,6 +2650,27 @@ "node": ">=10" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1953,6 +2691,18 @@ "node": ">=14.0.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1962,6 +2712,34 @@ "wrappy": "1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1982,7 +2760,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2060,6 +2837,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2147,6 +2933,19 @@ ], "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -2157,12 +2956,51 @@ "once": "^1.3.1" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2210,6 +3048,15 @@ "node": ">= 12.13.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2265,6 +3112,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2294,6 +3157,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -2322,6 +3191,150 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2409,6 +3422,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -2531,6 +3553,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -2549,7 +3580,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2576,6 +3606,20 @@ "node": "*" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2605,19 +3649,36 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2693,7 +3754,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2766,6 +3826,21 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2815,7 +3890,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -2834,6 +3908,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index 04d47b7..d347478 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index a951ac2..56d44a5 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -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(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((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, + timeoutMs = MEMENTO_TIMEOUT_MS, +): Promise { + 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, + 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 { + const normalized = summary.trim(); + if (!normalized) return; + + const tasks: Promise[] = [ + 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)}`); } diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 1f3f204..def0390 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -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 }); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index b3a7b55..4e63061 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -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, }, diff --git a/src/claude-usage.ts b/src/claude-usage.ts index 3ed83c1..c98bc03 100644 --- a/src/claude-usage.ts +++ b/src/claude-usage.ts @@ -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 { return new Promise((resolve) => { let output = ''; @@ -135,6 +205,7 @@ export async function fetchClaudeUsageViaCli( env: { ...(process.env as Record), CLAUDE_BINARY: binary, + ...(configDir ? { CLAUDE_CONFIG_DIR: configDir } : {}), }, }); } catch (err) { @@ -181,3 +252,32 @@ export async function fetchClaudeUsageViaCli( }); }); } + +export async function fetchAllClaudeProfiles(): Promise { + 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 { + 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, + })); +} diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts new file mode 100644 index 0000000..39f1e5f --- /dev/null +++ b/src/codex-token-rotation.ts @@ -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(); +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, + }); +} diff --git a/src/memento-client.test.ts b/src/memento-client.test.ts new file mode 100644 index 0000000..00c0212 --- /dev/null +++ b/src/memento-client.test.ts @@ -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); + }); +}); diff --git a/src/memento-client.ts b/src/memento-client.ts new file mode 100644 index 0000000..092755d --- /dev/null +++ b/src/memento-client.ts @@ -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( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + return new Promise((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( + 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( + name: string, + args: Record, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + 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, + timeoutMs, + `${name}/call`, + ); + + if (result.isError) { + logger.warn({ toolName: name, args }, 'Memento tool returned an error'); + return null; + } + + return parseToolJson(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 { + const roomKey = buildRoomMemoryKey(args.groupFolder); + const recallResponse = await callMementoTool( + '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, + ); +} diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts new file mode 100644 index 0000000..0fc77c6 --- /dev/null +++ b/src/message-agent-executor.test.ts @@ -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, + ); + }); +}); diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index c1b4d8f..914bb57 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -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'; } diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 1af4b31..3916220 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -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'); diff --git a/src/token-rotation.ts b/src/token-rotation.ts new file mode 100644 index 0000000..0c7c7ca --- /dev/null +++ b/src/token-rotation.ts @@ -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(); + +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); +} diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 2e42cf7..e388cec 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -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(); 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 { @@ -339,86 +344,9 @@ function buildStatusContent(): string { return `${header}\n\n${sections}`; } -async function fetchClaudeUsage(): Promise { - 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 { +async function fetchCodexUsage( + codexHomeOverride?: string, +): Promise { 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 { const timer = setTimeout(() => finish(null), 20_000); + const spawnEnv: Record = { + ...(process.env as Record), + 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), - 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 { }); } -async function buildUsageContent(): Promise { - 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 { 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 { 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 { } 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 { 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 { + 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 { 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(