diff --git a/package-lock.json b/package-lock.json index 68caf77..f7a677d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@ai-sdk/openai": "^1.3.0", "@grammyjs/auto-retry": "^2.0.2", "ai": "^4.3.0", - "better-sqlite3": "^12.9.0", "chalk": "^5.4.0", "commander": "^12.1.0", "dotenv": "^16.4.7", @@ -39,7 +38,10 @@ "vitest": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.9.0" } }, "node_modules/@ai-sdk/anthropic": { @@ -1216,6 +1218,7 @@ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", "hasInstallScript": true, + "optional": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -1229,6 +1232,7 @@ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", + "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -1238,6 +1242,7 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", + "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -1263,6 +1268,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -1347,7 +1353,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/commander": { "version": "12.1.0", @@ -1399,6 +1406,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", + "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -1423,6 +1431,7 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", + "optional": true, "engines": { "node": ">=4.0.0" } @@ -1440,6 +1449,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -1465,6 +1475,7 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", + "optional": true, "dependencies": { "once": "^1.4.0" } @@ -1538,6 +1549,7 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", + "optional": true, "engines": { "node": ">=6" } @@ -1572,7 +1584,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", @@ -1589,7 +1602,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -1609,7 +1623,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/grammy": { "version": "1.42.0", @@ -1643,19 +1658,22 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/joycon": { "version": "3.1.1", @@ -1771,6 +1789,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=10" }, @@ -1783,6 +1802,7 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", + "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1791,7 +1811,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/mlly": { "version": "1.8.2", @@ -1842,13 +1863,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "license": "MIT", + "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -1930,6 +1953,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", + "optional": true, "dependencies": { "wrappy": "1" } @@ -2103,6 +2127,7 @@ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "license": "MIT", + "optional": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -2144,6 +2169,7 @@ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", + "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -2159,6 +2185,7 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -2186,6 +2213,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -2287,7 +2315,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/safe-stable-stringify": { "version": "2.5.0", @@ -2307,6 +2336,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", + "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -2338,7 +2368,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -2359,6 +2390,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -2416,6 +2448,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", + "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -2425,6 +2458,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=0.10.0" } @@ -2495,6 +2529,7 @@ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", + "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -2507,6 +2542,7 @@ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", + "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -2690,6 +2726,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", + "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -2734,7 +2771,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/uuid": { "version": "8.3.2", @@ -3403,7 +3441,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/yaml": { "version": "2.8.3", @@ -4101,6 +4140,7 @@ "version": "12.9.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "optional": true, "requires": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -4110,6 +4150,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, "requires": { "file-uri-to-path": "1.0.0" } @@ -4118,6 +4159,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "optional": true, "requires": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -4128,6 +4170,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "optional": true, "requires": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -4184,7 +4227,8 @@ "chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true }, "commander": { "version": "12.1.0", @@ -4221,6 +4265,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "optional": true, "requires": { "mimic-response": "^3.1.0" } @@ -4234,7 +4279,8 @@ "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "optional": true }, "dequal": { "version": "2.0.3", @@ -4244,7 +4290,8 @@ "detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true }, "diff-match-patch": { "version": "1.0.5", @@ -4260,6 +4307,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "optional": true, "requires": { "once": "^1.4.0" } @@ -4321,7 +4369,8 @@ "expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "optional": true }, "expect-type": { "version": "1.3.0", @@ -4339,7 +4388,8 @@ "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true }, "fix-dts-default-cjs-exports": { "version": "1.0.1", @@ -4355,7 +4405,8 @@ "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "optional": true }, "fsevents": { "version": "2.3.3", @@ -4367,7 +4418,8 @@ "github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "optional": true }, "grammy": { "version": "1.42.0", @@ -4383,17 +4435,20 @@ "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "optional": true }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "optional": true }, "ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "optional": true }, "joycon": { "version": "3.1.1", @@ -4480,17 +4535,20 @@ "mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "optional": true }, "minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "optional": true }, "mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "optional": true }, "mlly": { "version": "1.8.2", @@ -4528,12 +4586,14 @@ "napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "optional": true }, "node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "optional": true, "requires": { "semver": "^7.3.5" } @@ -4579,6 +4639,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "optional": true, "requires": { "wrappy": "1" } @@ -4684,6 +4745,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "optional": true, "requires": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -4708,6 +4770,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "optional": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -4722,6 +4785,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "optional": true, "requires": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -4742,6 +4806,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "optional": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -4803,7 +4868,8 @@ "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "optional": true }, "safe-stable-stringify": { "version": "2.5.0", @@ -4818,7 +4884,8 @@ "semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "optional": true }, "siginfo": { "version": "2.0.0", @@ -4829,12 +4896,14 @@ "simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "optional": true }, "simple-get": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "optional": true, "requires": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -4882,6 +4951,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, "requires": { "safe-buffer": "~5.2.0" } @@ -4889,7 +4959,8 @@ "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "optional": true }, "strip-literal": { "version": "3.1.0", @@ -4944,6 +5015,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "optional": true, "requires": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -4955,6 +5027,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "optional": true, "requires": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -5080,6 +5153,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "optional": true, "requires": { "safe-buffer": "^5.0.1" } @@ -5111,7 +5185,8 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "optional": true }, "uuid": { "version": "8.3.2", @@ -5422,7 +5497,8 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "optional": true }, "yaml": { "version": "2.8.3", diff --git a/src/core/agent.scheduled-task.test.ts b/src/core/agent.scheduled-task.test.ts new file mode 100644 index 0000000..4c5cce5 --- /dev/null +++ b/src/core/agent.scheduled-task.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ChannelMessage, ChannelType } from '../types/channel.js'; +import { logger } from '../utils/logger.js'; +import { Agent } from './agent.js'; +import type { ScheduledTaskManifest } from './scheduler.js'; + +type AgentHarness = { + enqueueMessage(message: ChannelMessage): void; + handleScheduledTask(manifest: ScheduledTaskManifest): Promise; + processInternalPrompt(prompt: string, channelId?: string, channelType?: ChannelType): Promise; +}; + +function createAgentHarness(): AgentHarness { + return Object.create(Agent.prototype) as AgentHarness; +} + +function createManifest(overrides: Partial = {}): ScheduledTaskManifest { + return { + id: 'scheduled-task', + description: 'Process scheduled task', + createdAt: '2026-04-25T00:00:00.000Z', + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Agent scheduled-task runtime path', () => { + it('keeps no-op inbox-patrol startup silent through handleScheduledTask', async () => { + const agent = createAgentHarness(); + const processSpy = vi.spyOn(agent, 'processInternalPrompt').mockResolvedValue(undefined); + + await agent.handleScheduledTask(createManifest({ + id: 'inbox-patrol-noop', + description: 'Process Jarvis Paperclip inbox every 30min', + skillName: 'inbox-patrol', + })); + + expect(processSpy).toHaveBeenCalledTimes(1); + expect(processSpy).toHaveBeenCalledWith( + 'Scheduled task triggered. Invoke the skill "inbox-patrol" using the use_skill tool and follow its instructions.', + undefined, + undefined, + ); + + const [prompt] = processSpy.mock.calls[0]!; + expect(prompt).not.toContain('Scheduled task started'); + expect(prompt).not.toContain('All actions auto-approved'); + }); + + it('preserves downstream prompt and routing for actionable scheduled work', async () => { + const agent = createAgentHarness(); + const processSpy = vi.spyOn(agent, 'processInternalPrompt').mockResolvedValue(undefined); + + await agent.handleScheduledTask(createManifest({ + id: 'inbox-patrol-actionable', + description: 'Escalate the inbox item that needs Leo', + prompt: 'Leo needs a decision on this Paperclip inbox item.', + skillName: 'inbox-patrol', + sourceChannelId: 'telegram:1044412428', + sourceChannelType: 'telegram', + })); + + expect(processSpy).toHaveBeenCalledWith( + 'Leo needs a decision on this Paperclip inbox item. Invoke the skill "inbox-patrol" using the use_skill tool and follow its instructions.', + 'telegram:1044412428', + 'telegram', + ); + }); + + it('falls back to the description when no prompt or skill is provided', async () => { + const agent = createAgentHarness(); + const processSpy = vi.spyOn(agent, 'processInternalPrompt').mockResolvedValue(undefined); + + await agent.handleScheduledTask(createManifest({ + id: 'description-fallback', + description: 'Sweep stale tasks', + })); + + expect(processSpy).toHaveBeenCalledWith( + 'Execute scheduled task: Sweep stale tasks', + undefined, + undefined, + ); + }); + + it('builds an internal system message by default in processInternalPrompt', async () => { + const agent = createAgentHarness(); + const enqueueSpy = vi.fn<(message: ChannelMessage) => void>(); + agent.enqueueMessage = enqueueSpy; + + await agent.processInternalPrompt('Check the Paperclip inbox.'); + + expect(enqueueSpy).toHaveBeenCalledTimes(1); + expect(enqueueSpy).toHaveBeenCalledWith(expect.objectContaining({ + channelId: 'internal', + channelType: 'internal', + senderId: 'system', + content: 'Check the Paperclip inbox.', + })); + }); + + it('preserves an explicit channel route in processInternalPrompt', async () => { + const agent = createAgentHarness(); + const enqueueSpy = vi.fn<(message: ChannelMessage) => void>(); + agent.enqueueMessage = enqueueSpy; + + await agent.processInternalPrompt( + 'Leo needs a decision on the inbox escalation.', + 'telegram:1044412428', + 'telegram', + ); + + expect(enqueueSpy).toHaveBeenCalledWith(expect.objectContaining({ + channelId: 'telegram:1044412428', + channelType: 'telegram', + senderId: 'system', + content: 'Leo needs a decision on the inbox escalation.', + })); + }); + + it('logs runtime failures instead of emitting a startup banner', async () => { + const agent = createAgentHarness(); + const runtimeError = new Error('Paperclip inbox unavailable'); + vi.spyOn(agent, 'processInternalPrompt').mockRejectedValue(runtimeError); + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined); + + await expect(agent.handleScheduledTask(createManifest({ + id: 'inbox-patrol-error', + description: 'Process Jarvis Paperclip inbox every 30min', + skillName: 'inbox-patrol', + }))).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + err: runtimeError, + task: 'inbox-patrol-error', + }), + 'Scheduled task execution failed', + ); + }); +}); diff --git a/src/core/agent.ts b/src/core/agent.ts index 9361b15..cfb1adc 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,1874 +1,1862 @@ -import { generateText, streamText } from 'ai'; -import type { ChannelMessage, ChannelType } from '../types/channel.js'; -import type { ProviderRegistry } from '../providers/registry.js'; -import type { Identity } from '../soul/identity.js'; -import type { ShortTermMemory, LongTermMemory, EpisodicMemory } from '../memory/store.js'; -import type { UserMemoryStore } from '../memory/user-memory.js'; -import type { ChannelRegistry } from '../channels/registry.js'; -import type { MercuryConfig } from '../utils/config.js'; -import type { TokenBudget } from '../utils/tokens.js'; -import type { CapabilityRegistry } from '../capabilities/registry.js'; -import type { ScheduledTaskManifest } from './scheduler.js'; -import { Lifecycle } from './lifecycle.js'; -import { Scheduler } from './scheduler.js'; -import { logger } from '../utils/logger.js'; -import { CLIChannel } from '../channels/cli.js'; -import { TelegramChannel } from '../channels/telegram.js'; -import { formatToolStep } from '../utils/tool-label.js'; -import type { ArrowSelectOption } from '../utils/arrow-select.js'; -import { - approveTelegramPendingRequest, - approveTelegramPendingRequestByPairingCode, - clearTelegramAccess, - demoteTelegramAdmin, - getTelegramAccessSummary, - getTelegramApprovedUsers, - getTelegramPendingRequests, - promoteTelegramUserToAdmin, - rejectTelegramPendingRequest, - removeTelegramUser, - saveConfig, -} from '../utils/config.js'; - -class ToolCallLoopDetector { - private recentCalls: Array<{ tool: string; params: string; failed: boolean }> = []; - private totalCalls = 0; - private hardAborted = false; - private recentStepTexts: Array = []; - private consecutiveNoActionSteps = 0; - - private static readonly ABSOLUTE_MAX = 25; - private static readonly FAILED_ABSOLUTE_MAX = 12; - private static readonly NO_ACTION_MAX = 5; - - private static readonly HIGH_TOLERANCE_TOOLS = new Set([ - 'fetch_url', - 'read_file', - 'list_dir', - 'web_search', - 'github_api', - ]); - - private static readonly IDENTICAL_THRESHOLD = 3; - private static readonly SIMILAR_THRESHOLD = 4; - private static readonly TEXT_REPEAT_THRESHOLD = 3; - private static readonly MAX_STEP_TEXTS = 12; - - private static getSameToolThreshold(toolName: string, failingCount: number): number { - const baseHigh = 5; - const baseNormal = 3; - const isHigh = ToolCallLoopDetector.HIGH_TOLERANCE_TOOLS.has(toolName); - let threshold = isHigh ? baseHigh : baseNormal; - if (failingCount >= 3) { - threshold = Math.min(threshold, isHigh ? 3 : 2); - } - return threshold; - } - - record(toolName: string, params: Record, failed: boolean = false): void { - const paramsKey = JSON.stringify(params).slice(0, 200); - this.recentCalls.push({ tool: toolName, params: paramsKey, failed }); - this.totalCalls++; - this.consecutiveNoActionSteps = 0; - if (this.recentCalls.length > 30) { - this.recentCalls.shift(); - } - } - - recordNoActionResult(): boolean { - this.consecutiveNoActionSteps++; - return this.consecutiveNoActionSteps >= ToolCallLoopDetector.NO_ACTION_MAX; - } - - recordStepText(text: string): void { - if (!text || text.length < 10) return; - const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 200); - if (!normalized) return; - this.recentStepTexts.push(normalized); - if (this.recentStepTexts.length > ToolCallLoopDetector.MAX_STEP_TEXTS) { - this.recentStepTexts.shift(); - } - } - - detectAbsoluteLimit(): boolean { - if (this.totalCalls >= ToolCallLoopDetector.ABSOLUTE_MAX) return true; - const failCount = this.recentCalls.filter(c => c.failed).length; - if (failCount >= ToolCallLoopDetector.FAILED_ABSOLUTE_MAX) return true; - return false; - } - - detectIdentical(): { tool: string; count: number; message: string } | null { - if (this.recentCalls.length < 3) return null; - - const last = this.recentCalls[this.recentCalls.length - 1]; - - let identicalCount = 0; - for (let i = this.recentCalls.length - 1; i >= 0; i--) { - if (this.recentCalls[i].tool === last.tool && this.recentCalls[i].params === last.params) { - identicalCount++; - } else { - break; - } - } - - if (identicalCount >= ToolCallLoopDetector.IDENTICAL_THRESHOLD) { - this.hardAborted = true; - return { - tool: last.tool, - count: identicalCount, - message: `[SYSTEM] You called "${last.tool}" ${identicalCount} times with identical parameters and got the same result. This is a hard loop — stop immediately.`, - }; - } - - return null; - } - - detectSimilarLoop(): { tool: string; count: number; message: string } | null { - if (this.recentCalls.length < 4) return null; - - const last = this.recentCalls[this.recentCalls.length - 1]; - let similarCount = 0; - - for (let i = this.recentCalls.length - 1; i >= 0; i--) { - const call = this.recentCalls[i]; - if (call.tool !== last.tool) break; - if (call.failed || last.failed) { - similarCount++; - } else { - break; - } - } - - if (similarCount >= ToolCallLoopDetector.SIMILAR_THRESHOLD) { - this.hardAborted = true; - return { - tool: last.tool, - count: similarCount, - message: `[SYSTEM] You called "${last.tool}" ${similarCount} times with different params but all are failing. This is a failing loop — stop immediately. Tell the user you cannot complete this task.`, - }; - } - - return null; - } - - detectTextRepetition(): { pattern: string; count: number } | null { - if (this.recentStepTexts.length < ToolCallLoopDetector.TEXT_REPEAT_THRESHOLD) return null; - - const texts = this.recentStepTexts; - const last = texts[texts.length - 1]; - - let repeatCount = 0; - for (let i = texts.length - 1; i >= 0; i--) { - const similarity = this.textSimilarity(last, texts[i]); - if (similarity >= 0.7) { - repeatCount++; - } else { - break; - } - } - - if (repeatCount >= ToolCallLoopDetector.TEXT_REPEAT_THRESHOLD) { - return { - pattern: last.slice(0, 60), - count: repeatCount, - }; - } - - return null; - } - - private textSimilarity(a: string, b: string): number { - if (a === b) return 1; - if (!a || !b) return 0; - - const setA = new Set(a.split(' ')); - const setB = new Set(b.split(' ')); - const intersection = [...setA].filter(w => setB.has(w)).length; - const union = new Set([...setA, ...setB]).size; - return union === 0 ? 0 : intersection / union; - } - - detectSameTool(): { tool: string; count: number } | null { - if (this.recentCalls.length < 3) return null; - - const last = this.recentCalls[this.recentCalls.length - 1]; - - let consecutiveCount = 0; - let failingConsecutive = 0; - for (let i = this.recentCalls.length - 1; i >= 0; i--) { - if (this.recentCalls[i].tool === last.tool) { - consecutiveCount++; - if (this.recentCalls[i].failed) failingConsecutive++; - } else { - break; - } - } - - const threshold = ToolCallLoopDetector.getSameToolThreshold(last.tool, failingConsecutive); - if (consecutiveCount >= threshold) { - return { tool: last.tool, count: consecutiveCount }; - } - - if (this.recentCalls.length >= 6) { - const lastN = this.recentCalls.slice(-6); - const toolCounts: Record = {}; - for (const call of lastN) { - toolCounts[call.tool] = (toolCounts[call.tool] || 0) + 1; - } - for (const [tool, count] of Object.entries(toolCounts)) { - if (count >= 5) { - return { tool, count }; - } - } - } - - return null; - } - - isHardAborted(): boolean { - return this.hardAborted; - } - - reset(): void { - this.recentCalls = []; - this.totalCalls = 0; - this.hardAborted = false; - this.recentStepTexts = []; - this.consecutiveNoActionSteps = 0; - } -} - -const MAX_STEPS = 10; - -export class Agent { - readonly lifecycle: Lifecycle; - readonly scheduler: Scheduler; - readonly capabilities: CapabilityRegistry; - private running = false; - private messageQueue: ChannelMessage[] = []; - private processing = false; - private telegramStreaming: boolean; - - constructor( - private config: MercuryConfig, - private providers: ProviderRegistry, - private identity: Identity, - private shortTerm: ShortTermMemory, - private longTerm: LongTermMemory, - private episodic: EpisodicMemory, - private userMemory: UserMemoryStore | null, - private channels: ChannelRegistry, - private tokenBudget: TokenBudget, - capabilities: CapabilityRegistry, - scheduler: Scheduler, - ) { - this.lifecycle = new Lifecycle(); - this.scheduler = scheduler; - this.capabilities = capabilities; - this.telegramStreaming = config.channels.telegram.streaming ?? true; - - this.scheduler.setOnScheduledTask(async (manifest) => this.handleScheduledTask(manifest)); - - this.channels.onIncomingMessage((msg) => this.enqueueMessage(msg)); - - this.scheduler.onHeartbeat(async () => { - await this.heartbeat(); - }); - } - - private enqueueMessage(msg: ChannelMessage): void { - logger.info({ from: msg.channelType, content: msg.content.slice(0, 50) }, 'Message enqueued'); - this.messageQueue.push(msg); - this.processQueue(); - } - - private async processQueue(): Promise { - if (this.processing) return; - if (this.messageQueue.length === 0) return; - if (!this.lifecycle.is('idle')) return; - - this.processing = true; - - while (this.messageQueue.length > 0) { - const msg = this.messageQueue.shift()!; - try { - await this.handleMessage(msg); - } catch (err) { - logger.error({ err, msg: msg.content.slice(0, 50) }, 'Failed to handle message'); - } - } - - this.processing = false; - } - - async birth(): Promise { - this.lifecycle.transition('birthing'); - logger.info({ name: this.config.identity.name }, 'Mercury is being born...'); - this.lifecycle.transition('onboarding'); - } - - async wake(): Promise { - this.lifecycle.transition('onboarding'); - this.lifecycle.transition('idle'); - this.scheduler.restorePersistedTasks(); - this.scheduler.startHeartbeat(); - await this.channels.startAll(); - this.running = true; - - const activeChannels = this.channels.getActiveChannels(); - const toolNames = this.capabilities.getToolNames(); - logger.info({ channels: activeChannels, tools: toolNames }, 'Mercury is awake'); - } - - async sleep(): Promise { - this.running = false; - this.scheduler.stopAll(); - await this.channels.stopAll(); - this.lifecycle.transition('sleeping'); - logger.info('Mercury is sleeping'); - } - - private async handleMessage(msg: ChannelMessage): Promise { - this.lifecycle.transition('thinking'); - const startTime = Date.now(); - - const isInternal = msg.channelType === 'internal'; - const isScheduled = msg.senderId === 'system' && msg.channelType !== 'internal'; - if (isInternal || isScheduled) { - this.capabilities.permissions.setAutoApproveAll(true); - this.capabilities.permissions.addTempScope('/', true, true); - } - - try { - const trimmed = msg.content.trim(); - if (trimmed.startsWith('/budget')) { - const subcommand = trimmed.slice('/budget'.length).trim(); - await this.handleBudgetCommand(subcommand || 'status', msg.channelType, msg.channelId); - this.lifecycle.transition('idle'); - return; - } - - if (trimmed === '/budget_override') { - await this.handleBudgetCommand('override', msg.channelType, msg.channelId); - this.lifecycle.transition('idle'); - return; - } - if (trimmed === '/budget_reset') { - await this.handleBudgetCommand('reset', msg.channelType, msg.channelId); - this.lifecycle.transition('idle'); - return; - } - if (trimmed.startsWith('/budget_set')) { - const args = trimmed.slice('/budget_set'.length).trim(); - await this.handleBudgetCommand('set ' + args, msg.channelType, msg.channelId); - this.lifecycle.transition('idle'); - return; - } - if (trimmed.startsWith('/stream')) { - const sub = trimmed.slice('/stream'.length).trim().toLowerCase(); - if (sub === 'off') { - this.telegramStreaming = false; - } else if (sub === 'on') { - this.telegramStreaming = true; - } else { - this.telegramStreaming = !this.telegramStreaming; - } - const ch = this.channels.get(msg.channelType as any); - if (ch) await ch.send( - this.telegramStreaming - ? 'Telegram streaming enabled. Responses will appear progressively.' - : 'Telegram streaming disabled. Responses will arrive as a single message.', - msg.channelId, - ); - this.lifecycle.transition('idle'); - return; - } - - if (await this.handleChatCommand(trimmed, msg.channelType, msg.channelId)) { - this.lifecycle.transition('idle'); - return; - } - - if (this.tokenBudget.isOverBudget()) { - const channel = this.channels.getChannelForMessage(msg); - if (channel && msg.channelType !== 'internal') { - if (msg.channelType === 'cli') { - if (['1', '2', '3', '4'].includes(trimmed)) { - await this.handleBudgetCommand(trimmed, msg.channelType, msg.channelId); - this.lifecycle.transition('idle'); - return; - } - await this.handleBudgetOverrideCLI(channel, msg); - } else { - await channel.send( - `I've exceeded my daily token budget (${this.tokenBudget.getStatusText()}).\n\nYou can override this:\n• /budget override — allow one more request\n• /budget reset — reset usage to zero\n• /budget set — change daily budget`, - msg.channelId, - ); - } - } - this.lifecycle.transition('idle'); - return; - } - - const systemPrompt = this.buildSystemPrompt(); - const recentMemory = this.shortTerm.getRecent(msg.channelId, 10); - - const messages: any[] = []; - - const recentSteps = this.shortTerm.getRecent(msg.channelId, 6); - let loopWarning: string | null = null; - if (recentSteps.length >= 3) { - const toolCallPattern = /\[Using: (.+?)\]/g; - const toolCalls: string[] = []; - for (const m of recentSteps) { - if (m.role === 'assistant') { - let match; - while ((match = toolCallPattern.exec(m.content)) !== null) { - toolCalls.push(match[1]); - } - } - } - if (toolCalls.length >= 3) { - const last3 = toolCalls.slice(-3); - if (last3[0] === last3[1] && last3[1] === last3[2]) { - loopWarning = `[SYSTEM WARNING] You have called ${last3[0]} 3+ times in a row with the same result. Stop repeating this call. Try a different approach — if you're failing on permissions, try a different path. If you're failing on git push auth, use github_api with PUT /repos/{owner}/{repo}/contents/{path} to push files directly through the API.`; - } - } - - if (!loopWarning) { - const assistantMessages = recentSteps.filter(m => m.role === 'assistant' && m.content.length > 20); - if (assistantMessages.length >= 3) { - const last3 = assistantMessages.slice(-3); - const normalizeText = (t: string) => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim().slice(0, 150); - const normalized = last3.map(m => normalizeText(m.content)); - const words0 = new Set(normalized[0].split(' ')); - const overlap01 = normalized[0] && normalized[1] ? [...words0].filter(w => new Set(normalized[1].split(' ')).has(w)).length / Math.max(words0.size, 1) : 0; - const overlap12 = normalized[1] && normalized[2] ? [...new Set(normalized[1].split(' '))].filter(w => new Set(normalized[2].split(' ')).has(w)).length / Math.max(new Set(normalized[1].split(' ')).size, 1) : 0; - if (overlap01 > 0.75 && overlap12 > 0.75) { - loopWarning = `[SYSTEM WARNING] Your last 3 responses are nearly identical. You are stuck in a text repetition loop. Stop immediately and give a completely different response. If you cannot complete the task, tell the user clearly why.`; - } - } - } - } - - if (loopWarning) { - messages.push({ role: 'user', content: loopWarning }); - messages.push({ role: 'assistant', content: 'Acknowledged. I will stop repeating and respond differently, or clearly state if the task cannot be completed.' }); - } - - if (this.userMemory) { - const memoryContext = this.userMemory.retrieveRelevant(msg.content, { maxRecords: 5, maxChars: 900 }); - if (memoryContext.context) { - messages.push({ - role: 'user', - content: memoryContext.context, - }); - messages.push({ role: 'assistant', content: 'Noted. I\'ll keep this in mind.' }); - } - } else { - const relevantFacts = this.longTerm.search(msg.content, 3); - if (relevantFacts.length > 0) { - messages.push({ - role: 'user', - content: 'Relevant facts from memory:\n' + relevantFacts.map(f => `- ${f.fact}`).join('\n'), - }); - messages.push({ role: 'assistant', content: 'Noted. I\'ll use these facts.' }); - } - } - - if (recentMemory.length > 0) { - for (const m of recentMemory) { - messages.push({ - role: m.role === 'user' ? 'user' : 'assistant', - content: m.content, - }); - } - } - - messages.push({ role: 'user', content: msg.content }); - - this.lifecycle.transition('responding'); - - const channel = this.channels.getChannelForMessage(msg); - if (channel) { - await channel.typing(msg.channelId).catch(() => {}); - } - - this.capabilities.setChannelContext(msg.channelId, msg.channelType); - this.capabilities.permissions.setCurrentChannelType(msg.channelType); - - const fallbackIterator = this.providers.getFallbackIterator(); - let result: any = null; - let usedProvider: { name: string; model: string } | null = null; - let lastError: any = null; - let streamedText = ''; - const loopDetector = new ToolCallLoopDetector(); - const loopAbortController = new AbortController(); - let loopWarningSent = false; - - const canStream = msg.channelType === 'cli' || (msg.channelType === 'telegram' && this.telegramStreaming); - - const tgChannel = this.channels.get('telegram'); - if (msg.channelType === 'telegram' && tgChannel) { - (tgChannel as TelegramChannel).resetStepCounter(msg.channelId); - } - - for (const provider of fallbackIterator) { - try { - logger.info({ provider: provider.name, model: provider.getModel(), steps: MAX_STEPS, stream: canStream }, 'Generating agentic response'); - - if (canStream && channel) { - const streamResult = streamText({ - model: provider.getModelInstance(), - system: systemPrompt, - messages, - tools: this.capabilities.getTools(), - maxSteps: MAX_STEPS, - abortSignal: loopAbortController.signal, - onStepFinish: async ({ toolCalls, toolResults }) => { - if (toolCalls && toolResults && toolCalls.length > 0) { - const names = toolCalls.map((tc: any) => tc.toolName).join(', '); - logger.info({ tools: names }, 'Tool call step'); - for (let i = 0; i < toolCalls.length; i++) { - const tc = toolCalls[i]; - const tr = toolResults[i] as any; - const resultStr = typeof tr?.result === 'string' ? tr.result : JSON.stringify(tr?.result ?? ''); - const failed = resultStr.length < 5000 && ( - resultStr.startsWith('Error:') || - resultStr.startsWith('⚠') || - resultStr.includes('exited with code') || - resultStr.includes('Command failed') || - resultStr.startsWith('Command exited with code') - ); - loopDetector.record(tc.toolName, tc.args as Record, failed); - } - if (loopDetector.detectAbsoluteLimit()) { - logger.warn('Absolute tool call limit reached — aborting'); - if (channel && msg.channelType !== 'internal') { - await channel.send('⚠ Tool call limit reached (25 calls). Stopping to prevent runaway loop.', msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - if (toolCalls.some((tc: any) => tc.toolName === 'use_skill')) { - loopDetector.reset(); - } - const hardLoop = loopDetector.detectIdentical(); - if (hardLoop) { - logger.warn({ tool: hardLoop.tool, count: hardLoop.count }, 'Hard loop detected — aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send(`⚠ Repeated call detected — ${hardLoop.tool} called ${hardLoop.count}x with same params. Stopping.`, msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - const similarLoop = loopDetector.detectSimilarLoop(); - if (similarLoop) { - logger.warn({ tool: similarLoop.tool, count: similarLoop.count }, 'Failing loop detected — aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send(`⚠ Failing loop detected — ${similarLoop.tool} called ${similarLoop.count}x, all failing. Stopping.`, msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - const softLoop = loopDetector.detectSameTool(); - if (softLoop && !loopWarningSent && channel && msg.channelType !== 'internal') { - if (this.capabilities.permissions.isAutoApproveAll()) { - loopDetector.reset(); - loopWarningSent = false; - } else { - loopWarningSent = true; - const shouldContinue = await channel.askToContinue( - `${softLoop.tool} has been called ${softLoop.count}x in a row. This might be a loop.`, - msg.channelId, - ).catch(() => false); - if (shouldContinue) { - loopDetector.reset(); - loopWarningSent = false; - } else { - loopAbortController.abort(); - } - } - } - if (channel && msg.channelType !== 'internal') { - if (channel instanceof CLIChannel) { - for (const tc of toolCalls) { - await (channel as CLIChannel).sendToolFeedback(tc.toolName, tc.args as Record).catch(() => {}); - } - if (toolResults) { - for (let i = 0; i < toolResults.length; i++) { - const tr = toolResults[i] as any; - const tcName = toolCalls[i]?.toolName as string | undefined; - if (tcName) { - (channel as CLIChannel).sendStepDone(tcName, tr.result ?? tr); - } - } - } - } else if (channel instanceof TelegramChannel) { - const tgCh = channel as TelegramChannel; - for (const tc of toolCalls) { - await tgCh.sendToolFeedback(tc.toolName, tc.args as Record, msg.channelId).catch(() => {}); - } - if (toolResults) { - for (let i = 0; i < toolResults.length; i++) { - const tr = toolResults[i] as any; - const tcName = toolCalls[i]?.toolName as string | undefined; - if (tcName) { - await tgCh.sendStepDone(tcName, tr.result ?? tr, msg.channelId).catch(() => {}); - } - } - } - } else { - await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {}); - } - } - } else if (toolResults === undefined || (toolCalls === undefined)) { - const stepText = (toolResults as any)?.text ?? ''; - if (stepText) { - loopDetector.recordStepText(String(stepText)); - } - const noActionLoop = loopDetector.recordNoActionResult(); - if (noActionLoop) { - logger.warn('Reasoning loop detected — model keeps thinking without acting, aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send('⚠ I\'m stuck in a reasoning loop (thinking without taking action). Stopping.', msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - const textRepeat = loopDetector.detectTextRepetition(); - if (textRepeat) { - logger.warn({ pattern: textRepeat.pattern, count: textRepeat.count }, 'Text repetition loop detected — aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send('⚠ I keep generating the same response. Stopping to prevent repetition.', msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - } - } - }, - }); - - let fullText: string; - - if (msg.channelType === 'telegram') { - const tgChannel = this.channels.get('telegram'); - if (tgChannel && 'sendStreamToChat' in tgChannel) { - const chatId = msg.channelId.startsWith('telegram:') - ? Number(msg.channelId.split(':')[1]) - : Number(msg.channelId); - if (!isNaN(chatId)) { - fullText = await (tgChannel as any).sendStreamToChat(chatId, streamResult.textStream); - } else { - fullText = await channel.stream(streamResult.textStream, msg.channelId); - } - } else { - fullText = await channel.stream(streamResult.textStream, msg.channelId); - } - } else { - fullText = await channel.stream(streamResult.textStream, msg.channelId); - } - - const [usage] = await Promise.all([ - streamResult.usage, - ]); - - result = { text: fullText, usage }; - streamedText = fullText; - loopDetector.recordStepText(fullText); - } else { - result = await generateText({ - model: provider.getModelInstance(), - system: systemPrompt, - messages, - tools: this.capabilities.getTools(), - maxSteps: MAX_STEPS, - abortSignal: loopAbortController.signal, - onStepFinish: async ({ toolCalls, toolResults }) => { - if (toolCalls && toolResults && toolCalls.length > 0) { - const names = toolCalls.map((tc: any) => tc.toolName).join(', '); - logger.info({ tools: names }, 'Tool call step'); - for (let i = 0; i < toolCalls.length; i++) { - const tc = toolCalls[i]; - const tr = toolResults[i] as any; - const resultStr = typeof tr?.result === 'string' ? tr.result : JSON.stringify(tr?.result ?? ''); - const failed = resultStr.length < 5000 && ( - resultStr.startsWith('Error:') || - resultStr.startsWith('⚠') || - resultStr.includes('exited with code') || - resultStr.includes('Command failed') || - resultStr.startsWith('Command exited with code') - ); - loopDetector.record(tc.toolName, tc.args as Record, failed); - } - if (loopDetector.detectAbsoluteLimit()) { - logger.warn('Absolute tool call limit reached — aborting'); - if (channel && msg.channelType !== 'internal') { - await channel.send('⚠ Tool call limit reached (25 calls). Stopping to prevent runaway loop.', msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - if (toolCalls.some((tc: any) => tc.toolName === 'use_skill')) { - loopDetector.reset(); - } - const hardLoop = loopDetector.detectIdentical(); - if (hardLoop) { - logger.warn({ tool: hardLoop.tool, count: hardLoop.count }, 'Hard loop detected — aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send(`⚠ Repeated call detected — ${hardLoop.tool} called ${hardLoop.count}x with same params. Stopping.`, msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - const similarLoop = loopDetector.detectSimilarLoop(); - if (similarLoop) { - logger.warn({ tool: similarLoop.tool, count: similarLoop.count }, 'Failing loop detected — aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send(`⚠ Failing loop detected — ${similarLoop.tool} called ${similarLoop.count}x, all failing. Stopping.`, msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - const softLoop = loopDetector.detectSameTool(); - if (softLoop && !loopWarningSent && channel && msg.channelType !== 'internal') { - if (this.capabilities.permissions.isAutoApproveAll()) { - loopDetector.reset(); - loopWarningSent = false; - } else { - loopWarningSent = true; - const shouldContinue = await channel.askToContinue( - `${softLoop.tool} has been called ${softLoop.count}x in a row. This might be a loop.`, - msg.channelId, - ).catch(() => false); - if (shouldContinue) { - loopDetector.reset(); - loopWarningSent = false; - } else { - loopAbortController.abort(); - } - } - } - if (channel && msg.channelType !== 'internal') { - if (channel instanceof CLIChannel) { - for (const tc of toolCalls) { - await (channel as CLIChannel).sendToolFeedback(tc.toolName, tc.args as Record).catch(() => {}); - } - if (toolResults) { - for (let i = 0; i < toolResults.length; i++) { - const tr = toolResults[i] as any; - const tcName = toolCalls[i]?.toolName as string | undefined; - if (tcName) { - (channel as CLIChannel).sendStepDone(tcName, tr.result ?? tr); - } - } - } - } else if (channel instanceof TelegramChannel) { - const tgCh = channel as TelegramChannel; - for (const tc of toolCalls) { - await tgCh.sendToolFeedback(tc.toolName, tc.args as Record, msg.channelId).catch(() => {}); - } - if (toolResults) { - for (let i = 0; i < toolResults.length; i++) { - const tr = toolResults[i] as any; - const tcName = toolCalls[i]?.toolName as string | undefined; - if (tcName) { - await tgCh.sendStepDone(tcName, tr.result ?? tr, msg.channelId).catch(() => {}); - } - } - } - } else { - await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {}); - } - } - } else if (toolResults === undefined || (toolCalls === undefined)) { - const stepText = (toolResults as any)?.text ?? ''; - if (stepText) { - loopDetector.recordStepText(String(stepText)); - } - const noActionLoop = loopDetector.recordNoActionResult(); - if (noActionLoop) { - logger.warn('Reasoning loop detected — model keeps thinking without acting, aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send('⚠ I\'m stuck in a reasoning loop (thinking without taking action). Stopping.', msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - return; - } - const textRepeat = loopDetector.detectTextRepetition(); - if (textRepeat) { - logger.warn({ pattern: textRepeat.pattern, count: textRepeat.count }, 'Text repetition loop detected — aborting'); - if (!loopWarningSent && channel && msg.channelType !== 'internal') { - loopWarningSent = true; - await channel.send('⚠ I keep generating the same response. Stopping to prevent repetition.', msg.channelId).catch(() => {}); - } - loopAbortController.abort(); - } - } - }, - }); - } - - usedProvider = { name: provider.name, model: provider.getModel() }; - this.providers.markSuccess(provider.name); - break; - } catch (err: any) { - if (loopDetector.isHardAborted() || loopAbortController.signal.aborted) { - logger.info('Generation aborted due to loop detection — using partial response'); - if (!result && streamedText) { - result = { text: streamedText, usage: undefined }; - } - if (!result) { - result = { text: 'I stopped because I detected I was stuck in a loop (repeating the same action without progress). I cannot complete this task as requested. Please let me know if you\'d like me to try a completely different approach, or if there\'s something else I can help with.', usage: undefined }; - } - if (usedProvider) { - this.providers.markSuccess(usedProvider.name); - } - break; - } - lastError = err; - logger.warn({ provider: provider.name, err: err.message }, 'Provider failed, trying fallback'); - if (channel && msg.channelType !== 'internal') { - await channel.send(` [Provider ${provider.name} failed, trying fallback...]`, msg.channelId).catch(() => {}); - } - } - } - - if (!result) { - const errMsg = `All LLM providers failed. Last error: ${lastError?.message || 'unknown'}`; - logger.error({ err: lastError }, errMsg); - if (channel && msg.channelType !== 'internal') { - await channel.send(errMsg, msg.channelId); - } - this.lifecycle.transition('idle'); - return; - } - - const finalText = (streamedText || result.text || '').trim() || '(no text response)'; - - this.tokenBudget.recordUsage({ - provider: usedProvider!.name, - model: usedProvider!.model, - inputTokens: result.usage?.promptTokens ?? 0, - outputTokens: result.usage?.completionTokens ?? 0, - totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0), - channelType: msg.channelType, - }); - - this.shortTerm.add(msg.channelId, { - id: msg.id, - timestamp: msg.timestamp, - role: 'user', - content: msg.content, - }); - - this.shortTerm.add(msg.channelId, { - id: Date.now().toString(36), - timestamp: Date.now(), - role: 'assistant', - content: finalText, - tokenCount: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0), - }); - - this.episodic.record({ - type: 'message', - summary: `User: ${msg.content.slice(0, 100)} | Agent: ${finalText.slice(0, 100)}`, - channelType: msg.channelType, - }); - - if (msg.channelType !== 'internal') { - this.extractMemory(msg.content, finalText).catch(err => { - logger.warn({ err }, 'Memory extraction failed'); - }); - } - - if (channel && msg.channelType !== 'internal') { - const elapsed = Date.now() - startTime; - if (streamedText && streamedText.trim()) { - logger.info({ channelType: msg.channelType, elapsed }, 'Streamed response completed'); - } else { - logger.info({ channelType: msg.channelType, targetId: msg.channelId }, 'Sending response'); - await channel.send(finalText, msg.channelId, elapsed); - } - } else { - logger.debug('Internal prompt processed, no channel response needed'); - } - - this.lifecycle.transition('idle'); - } catch (err) { - logger.error({ err }, 'Error handling message'); - this.lifecycle.transition('idle'); - } finally { - if (isInternal || isScheduled) { - this.capabilities.permissions.setAutoApproveAll(false); - } - this.capabilities.permissions.clearElevation(); - } - } - - private buildSystemPrompt(): string { - let prompt = this.identity.getSystemPrompt(this.config.identity); - const skillContext = this.capabilities.getSkillContext(); - if (skillContext) { - prompt += '\n\n' + skillContext; - } - const budgetStatus = this.tokenBudget.getStatusText(); - prompt += '\n\n' + budgetStatus; - if (this.tokenBudget.getUsagePercentage() > 70) { - prompt += '\nBe concise to conserve tokens.'; - } - - prompt += `\n\nEnvironment:\n- Platform: ${process.platform}\n- Working directory: ${this.capabilities.getCwd()}`; - - if (this.userMemory) { - const summary = this.userMemory.getSummary(); - prompt += `\n\nSecond Brain is ENABLED. You have a persistent, structured memory of ${summary.total} facts about this user.`; - prompt += `\nMemory types: identity, preference, goal, project, habit, decision, constraint, relationship, episode, reflection.`; - prompt += `\nRelevant memories are automatically injected before each message. You can reference them naturally (e.g. "I remember you prefer TypeScript").`; - prompt += `\nUsers can manage memory with: /memory (overview, search, pause learning, clear).`; - if (summary.learningPaused) { - prompt += `\nLearning is currently PAUSED — no new memories will be extracted from conversations until resumed.`; - } - } else { - prompt += '\n\nSecond Brain is DISABLED. Basic long-term memory (text search over facts) is still active.'; - } - - const toolNames = this.capabilities.getToolNames(); - const githubTools = ['create_pr', 'review_pr', 'list_issues', 'create_issue', 'github_api']; - const hasGitHub = githubTools.some(t => toolNames.includes(t)); - if (hasGitHub) { - let githubHint = '\n\nGitHub companion is active.'; - const { defaultOwner, defaultRepo } = this.config.github; - if (defaultOwner && defaultRepo) { - githubHint += ` Default repo: ${defaultOwner}/${defaultRepo}. Use this when the user doesn't specify a repo.`; - } - - githubHint += ` - -Available GitHub tools and when to use them: -- git_add, git_commit, git_push: LOCAL git operations (stage, commit, push to a remote you have SSH/auth access to). All commits include "Co-authored-by: Mercury ". -- create_pr: Create a pull request on GitHub. The head branch must already exist on the remote. -- review_pr: Get PR details and optionally post a review comment. -- list_issues, create_issue: Browse and file issues. -- github_api: Raw GitHub API access. IMPORTANT USE CASES: - - Push files directly to GitHub via PUT /repos/{owner}/{repo}/contents/{path} when git push fails due to auth. The body must include "message" and "content" (base64-encoded file content). This creates a commit on GitHub with Mercury as co-author. - - Delete files via DELETE /repos/{owner}/{repo}/contents/{path} with a "message" and "sha" in the body. - - Any other GitHub API operation not covered by the other tools. - -When the user asks to "push to GitHub" or "upload files" and git push fails, use github_api with PUT /repos/{owner}/{repo}/contents/{path} to push content directly through the API. This bypasses local git entirely. - -Always specify owner and repo parameters on GitHub tools. The user's GitHub username is ${this.config.github.username || 'not set'}.'`; - - prompt += githubHint; - } - return prompt; - } - - async processInternalPrompt(prompt: string, channelId?: string, channelType?: string): Promise { - const syntheticMsg: ChannelMessage = { - id: `internal-${Date.now().toString(36)}`, - channelId: channelId || 'internal', - channelType: (channelType || 'internal') as ChannelType, - senderId: 'system', - content: prompt, - timestamp: Date.now(), - }; - this.enqueueMessage(syntheticMsg); - } - - private async handleScheduledTask(manifest: ScheduledTaskManifest): Promise { - logger.info({ task: manifest.id, channel: manifest.sourceChannelType }, 'Processing scheduled task'); - try { - const channel = manifest.sourceChannelType - ? this.channels.get(manifest.sourceChannelType as ChannelType) - : this.channels.getNotificationChannel(); - - if (channel && manifest.sourceChannelType !== 'internal') { - const skillInfo = manifest.skillName ? ` (${manifest.skillName})` : ''; - await channel.send( - ` Scheduled task started${skillInfo}: ${manifest.description}\nAll actions auto-approved for this run.`, - manifest.sourceChannelId, - ).catch(() => {}); - } - - let prompt = manifest.prompt || ''; - if (manifest.skillName) { - const skillHint = `Invoke the skill "${manifest.skillName}" using the use_skill tool and follow its instructions.`; - prompt = prompt ? `${prompt} ${skillHint}` : `Scheduled task triggered. ${skillHint}`; - } - if (!prompt) { - prompt = `Execute scheduled task: ${manifest.description}`; - } - await this.processInternalPrompt(prompt, manifest.sourceChannelId, manifest.sourceChannelType); - } catch (err) { - logger.error({ err, task: manifest.id }, 'Scheduled task execution failed'); - } - } - - private async heartbeat(): Promise { - logger.debug('Heartbeat tick'); - - const pruned = this.episodic.prune(7); - if (pruned > 0) { - logger.info({ pruned }, 'Episodic memory pruned'); - } - - if (this.userMemory) { - try { - const consolidation = this.userMemory.consolidate(); - if (consolidation.profileUpdated || consolidation.reflectionCount > 0) { - logger.info({ consolidation }, 'Second brain consolidated'); - } - - const pruning = this.userMemory.prune(); - if (pruning.activePruned > 0 || pruning.durablePruned > 0 || pruning.promoted > 0) { - logger.info({ pruning }, 'Second brain pruned'); - } - } catch (err) { - logger.warn({ err }, 'Second brain heartbeat error'); - } - } - - const notifications: string[] = []; - - const usagePct = this.tokenBudget.getUsagePercentage(); - if (usagePct >= 80) { - notifications.push(`Token budget at ${Math.round(usagePct)}% — ${this.tokenBudget.getRemaining().toLocaleString()} tokens remaining today.`); - } - - const pendingSchedules = this.scheduler.getManifests(); - const now = Date.now(); - for (const task of pendingSchedules) { - if (task.delaySeconds && task.executeAt) { - const executeAt = new Date(task.executeAt).getTime(); - const diffMin = Math.round((executeAt - now) / 60000); - if (diffMin > 0 && diffMin <= 5) { - notifications.push(`Task "${task.description}" fires in ${diffMin} minute${diffMin !== 1 ? 's' : ''}.`); - } - } - } - - if (notifications.length > 0) { - const channel = this.channels.getNotificationChannel(); - if (channel) { - const msg = notifications.join('\n'); - try { - await channel.send(msg, 'notification'); - } catch (err) { - logger.warn({ err }, 'Failed to send heartbeat notification'); - } - } - } - } - - private async extractMemory(userMessage: string, agentResponse: string): Promise { - if (!this.userMemory) return; - if (this.userMemory.isLearningPaused()) return; - - const trivial = /^(hi|hello|hey|thanks|thank you|ok|okay|yes|no|bye|goodbye|good morning|good evening)\b/i; - if (trivial.test(userMessage.trim())) return; - - if (!this.tokenBudget.canAfford(800)) return; - - try { - const provider = this.providers.getDefault(); - const result = await generateText({ - model: provider.getModelInstance(), - system: `You extract structured memory from conversations. Read the conversation and output a JSON array of memory candidates. Each candidate has: type (one of: identity, preference, goal, project, habit, decision, constraint, relationship, episode), summary (concise fact, 12-220 chars), detail (optional longer explanation), evidenceKind (direct for explicitly stated facts, inferred for patterns you notice), confidence (0.0-1.0), importance (0.0-1.0), durability (0.0-1.0). Extract 0-3 candidates. Only extract specific, durable, user-specific information. Do NOT extract trivial observations, greetings, or assistant behavior. Output pure JSON array, no markdown.`, - messages: [ - { role: 'user', content: `User: ${userMessage}\nAssistant: ${agentResponse}` }, - ], - maxTokens: 400, - }); - - this.tokenBudget.recordUsage({ - provider: provider.name, - model: provider.getModel(), - inputTokens: result.usage?.promptTokens ?? 0, - outputTokens: result.usage?.completionTokens ?? 0, - totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0), - channelType: 'internal', - }); - - const text = result.text.trim(); - if (!text) return; - - let candidates: Array<{ - type: string; - summary: string; - detail?: string; - evidenceKind?: string; - confidence: number; - importance: number; - durability: number; - }>; - - try { - const jsonStr = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); - candidates = JSON.parse(jsonStr); - } catch { - const facts = text - .split('\n') - .map(l => l.replace(/^-\s*/, '').trim()) - .filter(f => f.length > 10 && f.length < 200); - candidates = facts.slice(0, 3).map(f => ({ - type: 'preference', - summary: f, - confidence: 0.75, - importance: 0.7, - durability: 0.7, - evidenceKind: 'inferred', - })); - } - - const validTypes = ['identity', 'preference', 'goal', 'project', 'habit', 'decision', 'constraint', 'relationship', 'episode']; - const typed = candidates - .filter(c => c.summary && c.summary.length >= 12 && c.summary.length <= 220) - .filter(c => validTypes.includes(c.type)) - .map(c => ({ - type: c.type as any, - summary: c.summary, - detail: c.detail, - evidenceKind: (c.evidenceKind === 'direct' ? 'direct' : 'inferred') as 'direct' | 'inferred', - confidence: Math.min(1, Math.max(0, c.confidence ?? 0.7)), - importance: Math.min(1, Math.max(0, c.importance ?? 0.7)), - durability: Math.min(1, Math.max(0, c.durability ?? 0.7)), - })); - - if (typed.length > 0) { - const remembered = this.userMemory.remember(typed, 'conversation'); - if (remembered.length > 0) { - logger.info({ count: remembered.length, types: remembered.map(r => r.type) }, 'Second brain memories stored'); - } - } - } catch (err) { - logger.warn({ err }, 'Memory extraction error'); - } - } - - async shutdown(): Promise { - await this.sleep(); - logger.info('Mercury has shut down'); - } - - private async handleBudgetOverrideCLI(channel: import('../channels/base.js').Channel, msg: ChannelMessage): Promise { - const status = this.tokenBudget.getStatusText(); - await channel.send( - `Token budget exceeded! ${status}\n\nChoose an option:\n 1 — Override (allow this one request)\n 2 — Reset usage to zero\n 3 — Set a new daily budget (current: ${this.tokenBudget.getBudget().toLocaleString()})\n 4 — Cancel\n\nOr use /budget override, /budget reset, /budget set anytime.`, - msg.channelId, - ); - } - - async handleBudgetCommand(subcommand: string, channelType: string, channelId: string): Promise { - const channel = this.channels.get(channelType as any); - if (!channel) return; - - const parts = subcommand.trim().split(/\s+/); - const action = parts[0]?.toLowerCase(); - - if (action === 'override' || action === '1') { - this.tokenBudget.forceAllowNext(); - await channel.send('Budget override applied — your next request will proceed.', channelId); - } else if (action === 'reset' || action === '2') { - this.tokenBudget.resetUsage(); - await channel.send(`Usage reset to zero. ${this.tokenBudget.getStatusText()}`, channelId); - } else if (action === 'set' || action === '3') { - const newBudget = parseInt(parts[1], 10); - if (isNaN(newBudget) || newBudget <= 0) { - await channel.send('Please specify the new budget. Usage: `/budget set 100000` or type e.g. `3 100000`', channelId); - return; - } - this.tokenBudget.setBudget(newBudget); - await channel.send(`Daily budget updated to ${newBudget.toLocaleString()} tokens. ${this.tokenBudget.getStatusText()}`, channelId); - } else if (action === 'cancel' || action === '4') { - await channel.send(`Cancelled. ${this.tokenBudget.getStatusText()}`, channelId); - } else if (!action || action === 'status') { - await channel.send(this.tokenBudget.getStatusText(), channelId); - } else { - await channel.send(`Unknown budget command "${action}". Available: /budget, /budget override, /budget reset, /budget set , /budget status`, channelId); - } - } - - private async handleChatCommand(content: string, channelType: string, channelId: string): Promise { - const trimmed = content.trim(); - const cmd = trimmed.toLowerCase(); - const channel = this.channels.get(channelType as any); - if (!channel) return false; - - const ctx = this.capabilities.getChatCommandContext(); - if (!ctx) return false; - - if (cmd === '/help') { - await channel.send(ctx.manual(), channelId); - return true; - } - - if (cmd === '/status') { - const config = ctx.config(); - const budget = ctx.tokenBudget(); - const lines = [ - `**${config.identity.name}** — Status`, - `Owner: ${config.identity.owner || '(not set)'}`, - `Provider: ${config.providers.default}`, - `Telegram: ${config.channels.telegram.enabled ? 'enabled' : 'disabled'}`, - `Telegram access: ${getTelegramAccessSummary(config)}`, - `Budget: ${budget.getStatusText()}`, - `Skills: ${ctx.skillNames().length > 0 ? ctx.skillNames().join(', ') : 'none'}`, - ]; - await channel.send(lines.join('\n'), channelId); - return true; - } - - if (cmd === '/memory') { - if (!this.userMemory) { - await channel.send('Second brain is not enabled.', channelId); - return true; - } - - if (channelType === 'cli' && channel instanceof CLIChannel) { - await this.openCliMemoryMenu(channel, channelId); - return true; - } - - await this.sendMemoryOverview(channel, channelId); - return true; - } - - if (cmd.startsWith('/telegram')) { - if (channelType !== 'cli') { - await channel.send('`/telegram` is only available from the Mercury CLI chat.', channelId); - return true; - } - - const config = ctx.config(); - const rawSubcommand = trimmed.slice('/telegram'.length).trim(); - if (!rawSubcommand && channel instanceof CLIChannel) { - await channel.withMenu(async (select) => { - await this.openCliTelegramMenu(channel, channelId, select); - }); - return true; - } - - const parts = rawSubcommand.split(/\s+/).filter(Boolean); - const action = parts[0]?.toLowerCase() || 'help'; - const formatTelegramUser = (user: { - userId: number; - username?: string; - firstName?: string; - pairingCode?: string; - }) => { - const username = user.username ? ` (@${user.username})` : ''; - const firstName = user.firstName ? ` ${user.firstName}` : ''; - const pairingCode = user.pairingCode ? ` [code: ${user.pairingCode}]` : ''; - return `${user.userId}${username}${firstName}${pairingCode}`; - }; - - const sendTelegramOverview = async () => { - const lines = [ - '**Telegram Management**', - '', - `Access: ${getTelegramAccessSummary(config)}`, - `Admins: ${config.channels.telegram.admins.length > 0 ? config.channels.telegram.admins.map(formatTelegramUser).join(', ') : 'none'}`, - `Members: ${config.channels.telegram.members.length > 0 ? config.channels.telegram.members.map(formatTelegramUser).join(', ') : 'none'}`, - `Pending: ${config.channels.telegram.pending.length > 0 ? config.channels.telegram.pending.map(formatTelegramUser).join(', ') : 'none'}`, - '', - 'Commands:', - '• `/telegram pending`', - '• `/telegram users`', - '• `/telegram approve `', - '• `/telegram reject `', - '• `/telegram remove `', - '• `/telegram promote `', - '• `/telegram demote `', - '• `/telegram reset`', - ]; - await channel.send(lines.join('\n'), channelId); - }; - - if (action === 'help' || action === 'status') { - await sendTelegramOverview(); - return true; - } - - if (action === 'pending') { - const pending = getTelegramPendingRequests(config); - const lines = [ - '**Telegram Pending Requests**', - '', - pending.length > 0 ? pending.map(formatTelegramUser).join('\n') : 'No pending Telegram requests.', - ]; - await channel.send(lines.join('\n'), channelId); - return true; - } - - if (action === 'users') { - const approved = getTelegramApprovedUsers(config); - const lines = [ - '**Telegram Approved Users**', - '', - `Admins: ${config.channels.telegram.admins.length > 0 ? config.channels.telegram.admins.map(formatTelegramUser).join(', ') : 'none'}`, - `Members: ${config.channels.telegram.members.length > 0 ? config.channels.telegram.members.map(formatTelegramUser).join(', ') : 'none'}`, - '', - `Total approved: ${approved.length}`, - ]; - await channel.send(lines.join('\n'), channelId); - return true; - } - - if (action === 'approve') { - const value = parts[1]; - if (!value) { - await channel.send('Usage: `/telegram approve `', channelId); - return true; - } - - let approved = approveTelegramPendingRequestByPairingCode(config, value); - let resultLabel = value; - - if (!approved) { - const userId = Number(value); - if (!isNaN(userId)) { - approved = approveTelegramPendingRequest(config, userId, 'member'); - resultLabel = userId.toString(); - } - } - - if (!approved) { - await channel.send(`No pending Telegram request found for \`${resultLabel}\`.`, channelId); - return true; - } - - saveConfig(config); - await channel.send(`Approved Telegram user ${formatTelegramUser(approved)}.`, channelId); - return true; - } - - if (action === 'reject') { - const value = Number(parts[1]); - if (isNaN(value)) { - await channel.send('Usage: `/telegram reject `', channelId); - return true; - } - - const rejected = rejectTelegramPendingRequest(config, value); - if (!rejected) { - await channel.send(`No pending Telegram request found for \`${value}\`.`, channelId); - return true; - } - - saveConfig(config); - await channel.send(`Rejected Telegram request for ${formatTelegramUser(rejected)}.`, channelId); - return true; - } - - if (action === 'remove') { - const value = Number(parts[1]); - if (isNaN(value)) { - await channel.send('Usage: `/telegram remove `', channelId); - return true; - } - - const removed = removeTelegramUser(config, value); - if (!removed) { - await channel.send(`No approved Telegram user found for \`${value}\`.`, channelId); - return true; - } - - saveConfig(config); - await channel.send(`Removed Telegram access for ${formatTelegramUser(removed)}.`, channelId); - return true; - } - - if (action === 'promote') { - const value = Number(parts[1]); - if (isNaN(value)) { - await channel.send('Usage: `/telegram promote `', channelId); - return true; - } - - const promoted = promoteTelegramUserToAdmin(config, value); - if (!promoted) { - await channel.send(`No Telegram member found for \`${value}\`.`, channelId); - return true; - } - - saveConfig(config); - await channel.send(`Promoted ${formatTelegramUser(promoted)} to Telegram admin.`, channelId); - return true; - } - - if (action === 'demote') { - const value = Number(parts[1]); - if (isNaN(value)) { - await channel.send('Usage: `/telegram demote `', channelId); - return true; - } - - const demoted = demoteTelegramAdmin(config, value); - if (!demoted) { - await channel.send('Could not demote that Telegram admin. Mercury must keep at least one admin.', channelId); - return true; - } - - saveConfig(config); - await channel.send(`Demoted ${formatTelegramUser(demoted)} to Telegram member.`, channelId); - return true; - } - - if (action === 'reset' || action === 'unpair') { - config.channels.telegram.admins = []; - config.channels.telegram.members = []; - config.channels.telegram.pending = []; - saveConfig(config); - await channel.send('Telegram access reset. New users can send /start to begin pairing again.', channelId); - return true; - } - - await channel.send( - `Unknown Telegram command "${action}". Try \`/telegram\`, \`/telegram pending\`, or \`/telegram users\`.`, - channelId, - ); - return true; - } - - if ((cmd === '/' || cmd === '/menu') && channelType === 'cli' && channel instanceof CLIChannel) { - await this.openCliCommandMenu(channel, channelId); - return true; - } - - if (cmd === '/tools') { - const tools = ctx.toolNames(); - const grouped = [ - `**${tools.length} tools loaded:**`, - '', - ...tools.sort().map(t => `• \`${t}\``), - ]; - await channel.send(grouped.join('\n'), channelId); - return true; - } - - if (cmd === '/skills') { - const names = ctx.skillNames(); - if (names.length === 0) { - await channel.send('No skills installed. Ask me to "install skill from " to add one.', channelId); - } else { - const lines = [ - `**${names.length} skill${names.length > 1 ? 's' : ''} installed:**`, - '', - ...names.map(n => `• ${n}`), - ]; - await channel.send(lines.join('\n'), channelId); - } - return true; - } - - if (cmd === '/stream on') { - this.telegramStreaming = true; - await channel.send('Telegram streaming enabled. Responses will appear progressively.', channelId); - return true; - } - - if (cmd === '/stream off') { - this.telegramStreaming = false; - await channel.send('Telegram streaming disabled. Responses will arrive as a single message.', channelId); - return true; - } - - if (cmd === '/stream') { - this.telegramStreaming = !this.telegramStreaming; - await channel.send( - this.telegramStreaming - ? 'Telegram streaming enabled. Responses will appear progressively.' - : 'Telegram streaming disabled. Responses will arrive as a single message.', - channelId, - ); - return true; - } - if (cmd === '/stream off') { - this.telegramStreaming = false; - await channel.send('Telegram streaming disabled. Responses will arrive as a single message.', channelId); - return true; - } - - return false; - } - - private async openCliCommandMenu(channel: CLIChannel, channelId: string): Promise { - const ctx = this.capabilities.getChatCommandContext(); - if (!ctx) return; - - await channel.withMenu(async (select) => { - while (true) { - const streamLabel = this.telegramStreaming ? 'Disable Telegram Streaming' : 'Enable Telegram Streaming'; - const action = await select('Mercury Commands', [ - { value: 'status', label: 'Status' }, - { value: 'memory', label: 'Memory' }, - { value: 'telegram', label: 'Telegram' }, - { value: 'tools', label: 'Tools' }, - { value: 'skills', label: 'Skills' }, - { value: 'stream', label: streamLabel }, - { value: 'help', label: 'Help' }, - { value: 'exit', label: 'Exit' }, - ]); - - if (action === 'exit') { - return; - } - - if (action === 'status') { - await this.handleChatCommand('/status', 'cli', channelId); - continue; - } - - if (action === 'memory') { - if (this.userMemory) { - await this.openCliMemoryMenu(channel, channelId, select); - } else { - await channel.send('Second brain is not enabled.', channelId); - } - continue; - } - - if (action === 'telegram') { - await this.openCliTelegramMenu(channel, channelId, select); - continue; - } - - if (action === 'tools') { - await this.handleChatCommand('/tools', 'cli', channelId); - continue; - } - - if (action === 'skills') { - await this.handleChatCommand('/skills', 'cli', channelId); - continue; - } - - if (action === 'stream') { - await this.handleChatCommand('/stream', 'cli', channelId); - continue; - } - - if (action === 'help') { - await channel.send(ctx.manual(), channelId); - } - } - }); - } - - private async sendMemoryOverview(channel: any, channelId: string): Promise { - if (!this.userMemory) return; - const summary = this.userMemory.getSummary(); - const lines = [ - `**Memory Overview**`, - `Total memories: ${summary.total}`, - `Learning: ${summary.learningPaused ? 'PAUSED' : 'ACTIVE'}`, - ]; - if (summary.profileSummary) { - lines.push(`Profile: ${summary.profileSummary}`); - } - if (summary.activeSummary) { - lines.push(`Active: ${summary.activeSummary}`); - } - const typeEntries = Object.entries(summary.byType); - if (typeEntries.length > 0) { - lines.push(''); - lines.push('By type:'); - for (const [type, count] of typeEntries) { - lines.push(` ${type}: ${count}`); - } - } - await channel.send(lines.join('\n'), channelId); - } - - private async openCliMemoryMenu(channel: CLIChannel, channelId: string, select?: (title: string, options: ArrowSelectOption[]) => Promise): Promise { - if (!this.userMemory) return; - - const runMenu = async (sel: (title: string, options: ArrowSelectOption[]) => Promise) => { - while (true) { - const learningLabel = this.userMemory!.isLearningPaused() ? 'Resume Learning' : 'Pause Learning'; - const action = await sel('Memory', [ - { value: 'overview', label: 'Overview' }, - { value: 'recent', label: 'Recent Memories' }, - { value: 'search', label: 'Search' }, - { value: 'toggle', label: learningLabel }, - { value: 'clear', label: 'Clear All Memories' }, - { value: 'back', label: 'Back' }, - ]); - - if (action === 'back') return; - - if (action === 'overview') { - await this.sendMemoryOverview(channel, channelId); - continue; - } - - if (action === 'recent') { - const recent = this.userMemory!.getRecent(10); - if (recent.length === 0) { - await channel.send('No memories yet.', channelId); - continue; - } - const lines = ['**Recent Memories:**', '']; - for (const r of recent) { - const scope = r.scope === 'active' ? '⏳' : '📌'; - const kind = r.evidenceKind === 'direct' ? 'direct' : r.evidenceKind === 'inferred' ? 'inferred' : r.evidenceKind; - lines.push(`${scope} [${r.type}] ${r.summary}`); - lines.push(` Confidence: ${r.confidence.toFixed(2)} | Evidence: ${kind} | Seen: ${r.evidenceCount}x`); - } - await channel.send(lines.join('\n'), channelId); - continue; - } - - if (action === 'search') { - const query = await channel.prompt('Search memories: '); - if (!query) continue; - const results = this.userMemory!.search(query, 10); - if (results.length === 0) { - await channel.send(`No memories found matching "${query}".`, channelId); - continue; - } - const lines = [`**Search results for "${query}":**`, '']; - for (const r of results) { - const scope = r.scope === 'active' ? '⏳' : '📌'; - lines.push(`${scope} [${r.type}] ${r.summary}`); - lines.push(` Confidence: ${r.confidence.toFixed(2)} | Evidence: ${r.evidenceKind} | Seen: ${r.evidenceCount}x`); - } - await channel.send(lines.join('\n'), channelId); - continue; - } - - if (action === 'toggle') { - const currentlyPaused = this.userMemory!.isLearningPaused(); - this.userMemory!.setLearningPaused(!currentlyPaused); - await channel.send(currentlyPaused ? 'Learning resumed. Mercury will remember new things from conversations.' : 'Learning paused. Mercury will not store new memories until resumed.', channelId); - continue; - } - - if (action === 'clear') { - const confirm = await sel('Clear all memories?', [ - { value: 'cancel', label: 'Cancel' }, - { value: 'confirm', label: 'Clear everything' }, - ]); - if (confirm === 'confirm') { - const cleared = this.userMemory!.clear(); - await channel.send(`Cleared ${cleared} memories.`, channelId); - } - continue; - } - } - }; - - if (select) { - await runMenu(select); - } else { - await channel.withMenu(runMenu); - } - } - - private async openCliTelegramMenu( - channel: CLIChannel, - channelId: string, - select: (title: string, options: ArrowSelectOption[]) => Promise, - ): Promise { - const ctx = this.capabilities.getChatCommandContext(); - if (!ctx) return; - const formatTelegramUser = (user: { - userId: number; - username?: string; - firstName?: string; - pairingCode?: string; - }) => { - const username = user.username ? ` (@${user.username})` : ''; - const firstName = user.firstName ? ` ${user.firstName}` : ''; - const pairingCode = user.pairingCode ? ` [code: ${user.pairingCode}]` : ''; - return `${user.userId}${username}${firstName}${pairingCode}`; - }; - - const selectFromUsers = async ( - title: string, - users: Array<{ userId: number; username?: string; firstName?: string; pairingCode?: string }>, - emptyMessage: string, - backValue: string = 'back', - ): Promise => { - if (users.length === 0) { - await channel.send(emptyMessage, channelId); - return backValue; - } - - return select(title, [ - ...users.map((user) => ({ - value: user.pairingCode || user.userId.toString(), - label: formatTelegramUser(user), - })), - { value: backValue, label: 'Back' }, - ]); - }; - - while (true) { - const config = ctx.config(); - const action = await select('Telegram Commands', [ - { value: 'overview', label: 'Overview' }, - { value: 'pending', label: `Pending Requests (${config.channels.telegram.pending.length})` }, - { value: 'users', label: `Approved Users (${getTelegramApprovedUsers(config).length})` }, - { value: 'approve', label: 'Approve Request' }, - { value: 'reject', label: 'Reject Request' }, - { value: 'remove', label: 'Remove User' }, - { value: 'promote', label: 'Promote to Admin' }, - { value: 'demote', label: 'Demote Admin' }, - { value: 'reset', label: 'Reset Telegram Access' }, - { value: 'back', label: 'Back' }, - { value: 'exit', label: 'Exit' }, - ]); - - if (action === 'exit') { - return; - } - - if (action === 'back') { - return; - } - - if (action === 'overview') { - await this.handleChatCommand('/telegram status', 'cli', channelId); - continue; - } - - if (action === 'pending') { - await this.handleChatCommand('/telegram pending', 'cli', channelId); - continue; - } - - if (action === 'users') { - await this.handleChatCommand('/telegram users', 'cli', channelId); - continue; - } - - if (action === 'approve') { - const pending = getTelegramPendingRequests(config); - const selected = await selectFromUsers( - 'Approve Telegram Request', - pending, - 'There are no pending Telegram requests to approve.', - ); - - if (selected === 'back') { - continue; - } - - await this.handleChatCommand(`/telegram approve ${selected}`, 'cli', channelId); - continue; - } - - if (action === 'reject') { - const pending = getTelegramPendingRequests(config); - const selected = await selectFromUsers( - 'Reject Telegram Request', - pending, - 'There are no pending Telegram requests to reject.', - ); - - if (selected === 'back') { - continue; - } - - const request = pending.find((entry) => (entry.pairingCode || entry.userId.toString()) === selected); - if (!request) { - await channel.send('That Telegram request is no longer pending.', channelId); - continue; - } - - await this.handleChatCommand(`/telegram reject ${request.userId}`, 'cli', channelId); - continue; - } - - if (action === 'remove') { - const approved = getTelegramApprovedUsers(config); - const selected = await selectFromUsers( - 'Remove Telegram User', - approved, - 'There are no approved Telegram users to remove.', - ); - - if (selected === 'back') { - continue; - } - - const user = approved.find((entry) => entry.userId.toString() === selected); - if (!user) { - await channel.send('That Telegram user is no longer approved.', channelId); - continue; - } - - await this.handleChatCommand(`/telegram remove ${user.userId}`, 'cli', channelId); - continue; - } - - if (action === 'promote') { - const members = config.channels.telegram.members; - const selected = await selectFromUsers( - 'Promote Telegram Member', - members, - 'There are no Telegram members available to promote.', - ); - - if (selected === 'back') { - continue; - } - - const member = members.find((entry) => entry.userId.toString() === selected); - if (!member) { - await channel.send('That Telegram member is no longer available.', channelId); - continue; - } - - await this.handleChatCommand(`/telegram promote ${member.userId}`, 'cli', channelId); - continue; - } - - if (action === 'demote') { - const admins = config.channels.telegram.admins; - const selected = await selectFromUsers( - 'Demote Telegram Admin', - admins, - 'There are no Telegram admins available to demote.', - ); - - if (selected === 'back') { - continue; - } - - const admin = admins.find((entry) => entry.userId.toString() === selected); - if (!admin) { - await channel.send('That Telegram admin is no longer available.', channelId); - continue; - } - - await this.handleChatCommand(`/telegram demote ${admin.userId}`, 'cli', channelId); - continue; - } - - if (action === 'reset') { - const confirmation = await select('Reset Telegram Access?', [ - { value: 'cancel', label: 'Cancel' }, - { value: 'confirm', label: 'Reset all Telegram access' }, - { value: 'back', label: 'Back' }, - ]); - - if (confirmation === 'confirm') { - clearTelegramAccess(config); - saveConfig(config); - await channel.send('Telegram access reset. New users can send /start to begin pairing again.', channelId); - } - - continue; - } - } - } -} +import { generateText, streamText } from 'ai'; +import type { ChannelMessage, ChannelType } from '../types/channel.js'; +import type { ProviderRegistry } from '../providers/registry.js'; +import type { Identity } from '../soul/identity.js'; +import type { ShortTermMemory, LongTermMemory, EpisodicMemory } from '../memory/store.js'; +import type { UserMemoryStore } from '../memory/user-memory.js'; +import type { ChannelRegistry } from '../channels/registry.js'; +import type { MercuryConfig } from '../utils/config.js'; +import type { TokenBudget } from '../utils/tokens.js'; +import type { CapabilityRegistry } from '../capabilities/registry.js'; +import type { ScheduledTaskManifest } from './scheduler.js'; +import { Lifecycle } from './lifecycle.js'; +import { Scheduler } from './scheduler.js'; +import { logger } from '../utils/logger.js'; +import { CLIChannel } from '../channels/cli.js'; +import { TelegramChannel } from '../channels/telegram.js'; +import { formatToolStep } from '../utils/tool-label.js'; +import type { ArrowSelectOption } from '../utils/arrow-select.js'; +import { + approveTelegramPendingRequest, + approveTelegramPendingRequestByPairingCode, + clearTelegramAccess, + demoteTelegramAdmin, + getTelegramAccessSummary, + getTelegramApprovedUsers, + getTelegramPendingRequests, + promoteTelegramUserToAdmin, + rejectTelegramPendingRequest, + removeTelegramUser, + saveConfig, +} from '../utils/config.js'; + +class ToolCallLoopDetector { + private recentCalls: Array<{ tool: string; params: string; failed: boolean }> = []; + private totalCalls = 0; + private hardAborted = false; + private recentStepTexts: Array = []; + private consecutiveNoActionSteps = 0; + + private static readonly ABSOLUTE_MAX = 25; + private static readonly FAILED_ABSOLUTE_MAX = 12; + private static readonly NO_ACTION_MAX = 5; + + private static readonly HIGH_TOLERANCE_TOOLS = new Set([ + 'fetch_url', + 'read_file', + 'list_dir', + 'web_search', + 'github_api', + ]); + + private static readonly IDENTICAL_THRESHOLD = 3; + private static readonly SIMILAR_THRESHOLD = 4; + private static readonly TEXT_REPEAT_THRESHOLD = 3; + private static readonly MAX_STEP_TEXTS = 12; + + private static getSameToolThreshold(toolName: string, failingCount: number): number { + const baseHigh = 5; + const baseNormal = 3; + const isHigh = ToolCallLoopDetector.HIGH_TOLERANCE_TOOLS.has(toolName); + let threshold = isHigh ? baseHigh : baseNormal; + if (failingCount >= 3) { + threshold = Math.min(threshold, isHigh ? 3 : 2); + } + return threshold; + } + + record(toolName: string, params: Record, failed: boolean = false): void { + const paramsKey = JSON.stringify(params).slice(0, 200); + this.recentCalls.push({ tool: toolName, params: paramsKey, failed }); + this.totalCalls++; + this.consecutiveNoActionSteps = 0; + if (this.recentCalls.length > 30) { + this.recentCalls.shift(); + } + } + + recordNoActionResult(): boolean { + this.consecutiveNoActionSteps++; + return this.consecutiveNoActionSteps >= ToolCallLoopDetector.NO_ACTION_MAX; + } + + recordStepText(text: string): void { + if (!text || text.length < 10) return; + const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 200); + if (!normalized) return; + this.recentStepTexts.push(normalized); + if (this.recentStepTexts.length > ToolCallLoopDetector.MAX_STEP_TEXTS) { + this.recentStepTexts.shift(); + } + } + + detectAbsoluteLimit(): boolean { + if (this.totalCalls >= ToolCallLoopDetector.ABSOLUTE_MAX) return true; + const failCount = this.recentCalls.filter(c => c.failed).length; + if (failCount >= ToolCallLoopDetector.FAILED_ABSOLUTE_MAX) return true; + return false; + } + + detectIdentical(): { tool: string; count: number; message: string } | null { + if (this.recentCalls.length < 3) return null; + + const last = this.recentCalls[this.recentCalls.length - 1]; + + let identicalCount = 0; + for (let i = this.recentCalls.length - 1; i >= 0; i--) { + if (this.recentCalls[i].tool === last.tool && this.recentCalls[i].params === last.params) { + identicalCount++; + } else { + break; + } + } + + if (identicalCount >= ToolCallLoopDetector.IDENTICAL_THRESHOLD) { + this.hardAborted = true; + return { + tool: last.tool, + count: identicalCount, + message: `[SYSTEM] You called "${last.tool}" ${identicalCount} times with identical parameters and got the same result. This is a hard loop — stop immediately.`, + }; + } + + return null; + } + + detectSimilarLoop(): { tool: string; count: number; message: string } | null { + if (this.recentCalls.length < 4) return null; + + const last = this.recentCalls[this.recentCalls.length - 1]; + let similarCount = 0; + + for (let i = this.recentCalls.length - 1; i >= 0; i--) { + const call = this.recentCalls[i]; + if (call.tool !== last.tool) break; + if (call.failed || last.failed) { + similarCount++; + } else { + break; + } + } + + if (similarCount >= ToolCallLoopDetector.SIMILAR_THRESHOLD) { + this.hardAborted = true; + return { + tool: last.tool, + count: similarCount, + message: `[SYSTEM] You called "${last.tool}" ${similarCount} times with different params but all are failing. This is a failing loop — stop immediately. Tell the user you cannot complete this task.`, + }; + } + + return null; + } + + detectTextRepetition(): { pattern: string; count: number } | null { + if (this.recentStepTexts.length < ToolCallLoopDetector.TEXT_REPEAT_THRESHOLD) return null; + + const texts = this.recentStepTexts; + const last = texts[texts.length - 1]; + + let repeatCount = 0; + for (let i = texts.length - 1; i >= 0; i--) { + const similarity = this.textSimilarity(last, texts[i]); + if (similarity >= 0.7) { + repeatCount++; + } else { + break; + } + } + + if (repeatCount >= ToolCallLoopDetector.TEXT_REPEAT_THRESHOLD) { + return { + pattern: last.slice(0, 60), + count: repeatCount, + }; + } + + return null; + } + + private textSimilarity(a: string, b: string): number { + if (a === b) return 1; + if (!a || !b) return 0; + + const setA = new Set(a.split(' ')); + const setB = new Set(b.split(' ')); + const intersection = [...setA].filter(w => setB.has(w)).length; + const union = new Set([...setA, ...setB]).size; + return union === 0 ? 0 : intersection / union; + } + + detectSameTool(): { tool: string; count: number } | null { + if (this.recentCalls.length < 3) return null; + + const last = this.recentCalls[this.recentCalls.length - 1]; + + let consecutiveCount = 0; + let failingConsecutive = 0; + for (let i = this.recentCalls.length - 1; i >= 0; i--) { + if (this.recentCalls[i].tool === last.tool) { + consecutiveCount++; + if (this.recentCalls[i].failed) failingConsecutive++; + } else { + break; + } + } + + const threshold = ToolCallLoopDetector.getSameToolThreshold(last.tool, failingConsecutive); + if (consecutiveCount >= threshold) { + return { tool: last.tool, count: consecutiveCount }; + } + + if (this.recentCalls.length >= 6) { + const lastN = this.recentCalls.slice(-6); + const toolCounts: Record = {}; + for (const call of lastN) { + toolCounts[call.tool] = (toolCounts[call.tool] || 0) + 1; + } + for (const [tool, count] of Object.entries(toolCounts)) { + if (count >= 5) { + return { tool, count }; + } + } + } + + return null; + } + + isHardAborted(): boolean { + return this.hardAborted; + } + + reset(): void { + this.recentCalls = []; + this.totalCalls = 0; + this.hardAborted = false; + this.recentStepTexts = []; + this.consecutiveNoActionSteps = 0; + } +} + +const MAX_STEPS = 10; + +export class Agent { + readonly lifecycle: Lifecycle; + readonly scheduler: Scheduler; + readonly capabilities: CapabilityRegistry; + private running = false; + private messageQueue: ChannelMessage[] = []; + private processing = false; + private telegramStreaming: boolean; + + constructor( + private config: MercuryConfig, + private providers: ProviderRegistry, + private identity: Identity, + private shortTerm: ShortTermMemory, + private longTerm: LongTermMemory, + private episodic: EpisodicMemory, + private userMemory: UserMemoryStore | null, + private channels: ChannelRegistry, + private tokenBudget: TokenBudget, + capabilities: CapabilityRegistry, + scheduler: Scheduler, + ) { + this.lifecycle = new Lifecycle(); + this.scheduler = scheduler; + this.capabilities = capabilities; + this.telegramStreaming = config.channels.telegram.streaming ?? true; + + this.scheduler.setOnScheduledTask(async (manifest) => this.handleScheduledTask(manifest)); + + this.channels.onIncomingMessage((msg) => this.enqueueMessage(msg)); + + this.scheduler.onHeartbeat(async () => { + await this.heartbeat(); + }); + } + + private enqueueMessage(msg: ChannelMessage): void { + logger.info({ from: msg.channelType, content: msg.content.slice(0, 50) }, 'Message enqueued'); + this.messageQueue.push(msg); + this.processQueue(); + } + + private async processQueue(): Promise { + if (this.processing) return; + if (this.messageQueue.length === 0) return; + if (!this.lifecycle.is('idle')) return; + + this.processing = true; + + while (this.messageQueue.length > 0) { + const msg = this.messageQueue.shift()!; + try { + await this.handleMessage(msg); + } catch (err) { + logger.error({ err, msg: msg.content.slice(0, 50) }, 'Failed to handle message'); + } + } + + this.processing = false; + } + + async birth(): Promise { + this.lifecycle.transition('birthing'); + logger.info({ name: this.config.identity.name }, 'Mercury is being born...'); + this.lifecycle.transition('onboarding'); + } + + async wake(): Promise { + this.lifecycle.transition('onboarding'); + this.lifecycle.transition('idle'); + this.scheduler.restorePersistedTasks(); + this.scheduler.startHeartbeat(); + await this.channels.startAll(); + this.running = true; + + const activeChannels = this.channels.getActiveChannels(); + const toolNames = this.capabilities.getToolNames(); + logger.info({ channels: activeChannels, tools: toolNames }, 'Mercury is awake'); + } + + async sleep(): Promise { + this.running = false; + this.scheduler.stopAll(); + await this.channels.stopAll(); + this.lifecycle.transition('sleeping'); + logger.info('Mercury is sleeping'); + } + + private async handleMessage(msg: ChannelMessage): Promise { + this.lifecycle.transition('thinking'); + const startTime = Date.now(); + + const isInternal = msg.channelType === 'internal'; + const isScheduled = msg.senderId === 'system' && msg.channelType !== 'internal'; + if (isInternal || isScheduled) { + this.capabilities.permissions.setAutoApproveAll(true); + this.capabilities.permissions.addTempScope('/', true, true); + } + + try { + const trimmed = msg.content.trim(); + if (trimmed.startsWith('/budget')) { + const subcommand = trimmed.slice('/budget'.length).trim(); + await this.handleBudgetCommand(subcommand || 'status', msg.channelType, msg.channelId); + this.lifecycle.transition('idle'); + return; + } + + if (trimmed === '/budget_override') { + await this.handleBudgetCommand('override', msg.channelType, msg.channelId); + this.lifecycle.transition('idle'); + return; + } + if (trimmed === '/budget_reset') { + await this.handleBudgetCommand('reset', msg.channelType, msg.channelId); + this.lifecycle.transition('idle'); + return; + } + if (trimmed.startsWith('/budget_set')) { + const args = trimmed.slice('/budget_set'.length).trim(); + await this.handleBudgetCommand('set ' + args, msg.channelType, msg.channelId); + this.lifecycle.transition('idle'); + return; + } + if (trimmed.startsWith('/stream')) { + const sub = trimmed.slice('/stream'.length).trim().toLowerCase(); + if (sub === 'off') { + this.telegramStreaming = false; + } else if (sub === 'on') { + this.telegramStreaming = true; + } else { + this.telegramStreaming = !this.telegramStreaming; + } + const ch = this.channels.get(msg.channelType as any); + if (ch) await ch.send( + this.telegramStreaming + ? 'Telegram streaming enabled. Responses will appear progressively.' + : 'Telegram streaming disabled. Responses will arrive as a single message.', + msg.channelId, + ); + this.lifecycle.transition('idle'); + return; + } + + if (await this.handleChatCommand(trimmed, msg.channelType, msg.channelId)) { + this.lifecycle.transition('idle'); + return; + } + + if (this.tokenBudget.isOverBudget()) { + const channel = this.channels.getChannelForMessage(msg); + if (channel && msg.channelType !== 'internal') { + if (msg.channelType === 'cli') { + if (['1', '2', '3', '4'].includes(trimmed)) { + await this.handleBudgetCommand(trimmed, msg.channelType, msg.channelId); + this.lifecycle.transition('idle'); + return; + } + await this.handleBudgetOverrideCLI(channel, msg); + } else { + await channel.send( + `I've exceeded my daily token budget (${this.tokenBudget.getStatusText()}).\n\nYou can override this:\n• /budget override — allow one more request\n• /budget reset — reset usage to zero\n• /budget set — change daily budget`, + msg.channelId, + ); + } + } + this.lifecycle.transition('idle'); + return; + } + + const systemPrompt = this.buildSystemPrompt(); + const recentMemory = this.shortTerm.getRecent(msg.channelId, 10); + + const messages: any[] = []; + + const recentSteps = this.shortTerm.getRecent(msg.channelId, 6); + let loopWarning: string | null = null; + if (recentSteps.length >= 3) { + const toolCallPattern = /\[Using: (.+?)\]/g; + const toolCalls: string[] = []; + for (const m of recentSteps) { + if (m.role === 'assistant') { + let match; + while ((match = toolCallPattern.exec(m.content)) !== null) { + toolCalls.push(match[1]); + } + } + } + if (toolCalls.length >= 3) { + const last3 = toolCalls.slice(-3); + if (last3[0] === last3[1] && last3[1] === last3[2]) { + loopWarning = `[SYSTEM WARNING] You have called ${last3[0]} 3+ times in a row with the same result. Stop repeating this call. Try a different approach — if you're failing on permissions, try a different path. If you're failing on git push auth, use github_api with PUT /repos/{owner}/{repo}/contents/{path} to push files directly through the API.`; + } + } + + if (!loopWarning) { + const assistantMessages = recentSteps.filter(m => m.role === 'assistant' && m.content.length > 20); + if (assistantMessages.length >= 3) { + const last3 = assistantMessages.slice(-3); + const normalizeText = (t: string) => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim().slice(0, 150); + const normalized = last3.map(m => normalizeText(m.content)); + const words0 = new Set(normalized[0].split(' ')); + const overlap01 = normalized[0] && normalized[1] ? [...words0].filter(w => new Set(normalized[1].split(' ')).has(w)).length / Math.max(words0.size, 1) : 0; + const overlap12 = normalized[1] && normalized[2] ? [...new Set(normalized[1].split(' '))].filter(w => new Set(normalized[2].split(' ')).has(w)).length / Math.max(new Set(normalized[1].split(' ')).size, 1) : 0; + if (overlap01 > 0.75 && overlap12 > 0.75) { + loopWarning = `[SYSTEM WARNING] Your last 3 responses are nearly identical. You are stuck in a text repetition loop. Stop immediately and give a completely different response. If you cannot complete the task, tell the user clearly why.`; + } + } + } + } + + if (loopWarning) { + messages.push({ role: 'user', content: loopWarning }); + messages.push({ role: 'assistant', content: 'Acknowledged. I will stop repeating and respond differently, or clearly state if the task cannot be completed.' }); + } + + if (this.userMemory) { + const memoryContext = this.userMemory.retrieveRelevant(msg.content, { maxRecords: 5, maxChars: 900 }); + if (memoryContext.context) { + messages.push({ + role: 'user', + content: memoryContext.context, + }); + messages.push({ role: 'assistant', content: 'Noted. I\'ll keep this in mind.' }); + } + } else { + const relevantFacts = this.longTerm.search(msg.content, 3); + if (relevantFacts.length > 0) { + messages.push({ + role: 'user', + content: 'Relevant facts from memory:\n' + relevantFacts.map(f => `- ${f.fact}`).join('\n'), + }); + messages.push({ role: 'assistant', content: 'Noted. I\'ll use these facts.' }); + } + } + + if (recentMemory.length > 0) { + for (const m of recentMemory) { + messages.push({ + role: m.role === 'user' ? 'user' : 'assistant', + content: m.content, + }); + } + } + + messages.push({ role: 'user', content: msg.content }); + + this.lifecycle.transition('responding'); + + const channel = this.channels.getChannelForMessage(msg); + if (channel) { + await channel.typing(msg.channelId).catch(() => {}); + } + + this.capabilities.setChannelContext(msg.channelId, msg.channelType); + this.capabilities.permissions.setCurrentChannelType(msg.channelType); + + const fallbackIterator = this.providers.getFallbackIterator(); + let result: any = null; + let usedProvider: { name: string; model: string } | null = null; + let lastError: any = null; + let streamedText = ''; + const loopDetector = new ToolCallLoopDetector(); + const loopAbortController = new AbortController(); + let loopWarningSent = false; + + const canStream = msg.channelType === 'cli' || (msg.channelType === 'telegram' && this.telegramStreaming); + + const tgChannel = this.channels.get('telegram'); + if (msg.channelType === 'telegram' && tgChannel) { + (tgChannel as TelegramChannel).resetStepCounter(msg.channelId); + } + + for (const provider of fallbackIterator) { + try { + logger.info({ provider: provider.name, model: provider.getModel(), steps: MAX_STEPS, stream: canStream }, 'Generating agentic response'); + + if (canStream && channel) { + const streamResult = streamText({ + model: provider.getModelInstance(), + system: systemPrompt, + messages, + tools: this.capabilities.getTools(), + maxSteps: MAX_STEPS, + abortSignal: loopAbortController.signal, + onStepFinish: async ({ toolCalls, toolResults }) => { + if (toolCalls && toolResults && toolCalls.length > 0) { + const names = toolCalls.map((tc: any) => tc.toolName).join(', '); + logger.info({ tools: names }, 'Tool call step'); + for (let i = 0; i < toolCalls.length; i++) { + const tc = toolCalls[i]; + const tr = toolResults[i] as any; + const resultStr = typeof tr?.result === 'string' ? tr.result : JSON.stringify(tr?.result ?? ''); + const failed = resultStr.length < 5000 && ( + resultStr.startsWith('Error:') || + resultStr.startsWith('⚠') || + resultStr.includes('exited with code') || + resultStr.includes('Command failed') || + resultStr.startsWith('Command exited with code') + ); + loopDetector.record(tc.toolName, tc.args as Record, failed); + } + if (loopDetector.detectAbsoluteLimit()) { + logger.warn('Absolute tool call limit reached — aborting'); + if (channel && msg.channelType !== 'internal') { + await channel.send('⚠ Tool call limit reached (25 calls). Stopping to prevent runaway loop.', msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + if (toolCalls.some((tc: any) => tc.toolName === 'use_skill')) { + loopDetector.reset(); + } + const hardLoop = loopDetector.detectIdentical(); + if (hardLoop) { + logger.warn({ tool: hardLoop.tool, count: hardLoop.count }, 'Hard loop detected — aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send(`⚠ Repeated call detected — ${hardLoop.tool} called ${hardLoop.count}x with same params. Stopping.`, msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + const similarLoop = loopDetector.detectSimilarLoop(); + if (similarLoop) { + logger.warn({ tool: similarLoop.tool, count: similarLoop.count }, 'Failing loop detected — aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send(`⚠ Failing loop detected — ${similarLoop.tool} called ${similarLoop.count}x, all failing. Stopping.`, msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + const softLoop = loopDetector.detectSameTool(); + if (softLoop && !loopWarningSent && channel && msg.channelType !== 'internal') { + if (this.capabilities.permissions.isAutoApproveAll()) { + loopDetector.reset(); + loopWarningSent = false; + } else { + loopWarningSent = true; + const shouldContinue = await channel.askToContinue( + `${softLoop.tool} has been called ${softLoop.count}x in a row. This might be a loop.`, + msg.channelId, + ).catch(() => false); + if (shouldContinue) { + loopDetector.reset(); + loopWarningSent = false; + } else { + loopAbortController.abort(); + } + } + } + if (channel && msg.channelType !== 'internal') { + if (channel instanceof CLIChannel) { + for (const tc of toolCalls) { + await (channel as CLIChannel).sendToolFeedback(tc.toolName, tc.args as Record).catch(() => {}); + } + if (toolResults) { + for (let i = 0; i < toolResults.length; i++) { + const tr = toolResults[i] as any; + const tcName = toolCalls[i]?.toolName as string | undefined; + if (tcName) { + (channel as CLIChannel).sendStepDone(tcName, tr.result ?? tr); + } + } + } + } else if (channel instanceof TelegramChannel) { + const tgCh = channel as TelegramChannel; + for (const tc of toolCalls) { + await tgCh.sendToolFeedback(tc.toolName, tc.args as Record, msg.channelId).catch(() => {}); + } + if (toolResults) { + for (let i = 0; i < toolResults.length; i++) { + const tr = toolResults[i] as any; + const tcName = toolCalls[i]?.toolName as string | undefined; + if (tcName) { + await tgCh.sendStepDone(tcName, tr.result ?? tr, msg.channelId).catch(() => {}); + } + } + } + } else { + await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {}); + } + } + } else if (toolResults === undefined || (toolCalls === undefined)) { + const stepText = (toolResults as any)?.text ?? ''; + if (stepText) { + loopDetector.recordStepText(String(stepText)); + } + const noActionLoop = loopDetector.recordNoActionResult(); + if (noActionLoop) { + logger.warn('Reasoning loop detected — model keeps thinking without acting, aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send('⚠ I\'m stuck in a reasoning loop (thinking without taking action). Stopping.', msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + const textRepeat = loopDetector.detectTextRepetition(); + if (textRepeat) { + logger.warn({ pattern: textRepeat.pattern, count: textRepeat.count }, 'Text repetition loop detected — aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send('⚠ I keep generating the same response. Stopping to prevent repetition.', msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + } + } + }, + }); + + let fullText: string; + + if (msg.channelType === 'telegram') { + const tgChannel = this.channels.get('telegram'); + if (tgChannel && 'sendStreamToChat' in tgChannel) { + const chatId = msg.channelId.startsWith('telegram:') + ? Number(msg.channelId.split(':')[1]) + : Number(msg.channelId); + if (!isNaN(chatId)) { + fullText = await (tgChannel as any).sendStreamToChat(chatId, streamResult.textStream); + } else { + fullText = await channel.stream(streamResult.textStream, msg.channelId); + } + } else { + fullText = await channel.stream(streamResult.textStream, msg.channelId); + } + } else { + fullText = await channel.stream(streamResult.textStream, msg.channelId); + } + + const [usage] = await Promise.all([ + streamResult.usage, + ]); + + result = { text: fullText, usage }; + streamedText = fullText; + loopDetector.recordStepText(fullText); + } else { + result = await generateText({ + model: provider.getModelInstance(), + system: systemPrompt, + messages, + tools: this.capabilities.getTools(), + maxSteps: MAX_STEPS, + abortSignal: loopAbortController.signal, + onStepFinish: async ({ toolCalls, toolResults }) => { + if (toolCalls && toolResults && toolCalls.length > 0) { + const names = toolCalls.map((tc: any) => tc.toolName).join(', '); + logger.info({ tools: names }, 'Tool call step'); + for (let i = 0; i < toolCalls.length; i++) { + const tc = toolCalls[i]; + const tr = toolResults[i] as any; + const resultStr = typeof tr?.result === 'string' ? tr.result : JSON.stringify(tr?.result ?? ''); + const failed = resultStr.length < 5000 && ( + resultStr.startsWith('Error:') || + resultStr.startsWith('⚠') || + resultStr.includes('exited with code') || + resultStr.includes('Command failed') || + resultStr.startsWith('Command exited with code') + ); + loopDetector.record(tc.toolName, tc.args as Record, failed); + } + if (loopDetector.detectAbsoluteLimit()) { + logger.warn('Absolute tool call limit reached — aborting'); + if (channel && msg.channelType !== 'internal') { + await channel.send('⚠ Tool call limit reached (25 calls). Stopping to prevent runaway loop.', msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + if (toolCalls.some((tc: any) => tc.toolName === 'use_skill')) { + loopDetector.reset(); + } + const hardLoop = loopDetector.detectIdentical(); + if (hardLoop) { + logger.warn({ tool: hardLoop.tool, count: hardLoop.count }, 'Hard loop detected — aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send(`⚠ Repeated call detected — ${hardLoop.tool} called ${hardLoop.count}x with same params. Stopping.`, msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + const similarLoop = loopDetector.detectSimilarLoop(); + if (similarLoop) { + logger.warn({ tool: similarLoop.tool, count: similarLoop.count }, 'Failing loop detected — aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send(`⚠ Failing loop detected — ${similarLoop.tool} called ${similarLoop.count}x, all failing. Stopping.`, msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + const softLoop = loopDetector.detectSameTool(); + if (softLoop && !loopWarningSent && channel && msg.channelType !== 'internal') { + if (this.capabilities.permissions.isAutoApproveAll()) { + loopDetector.reset(); + loopWarningSent = false; + } else { + loopWarningSent = true; + const shouldContinue = await channel.askToContinue( + `${softLoop.tool} has been called ${softLoop.count}x in a row. This might be a loop.`, + msg.channelId, + ).catch(() => false); + if (shouldContinue) { + loopDetector.reset(); + loopWarningSent = false; + } else { + loopAbortController.abort(); + } + } + } + if (channel && msg.channelType !== 'internal') { + if (channel instanceof CLIChannel) { + for (const tc of toolCalls) { + await (channel as CLIChannel).sendToolFeedback(tc.toolName, tc.args as Record).catch(() => {}); + } + if (toolResults) { + for (let i = 0; i < toolResults.length; i++) { + const tr = toolResults[i] as any; + const tcName = toolCalls[i]?.toolName as string | undefined; + if (tcName) { + (channel as CLIChannel).sendStepDone(tcName, tr.result ?? tr); + } + } + } + } else if (channel instanceof TelegramChannel) { + const tgCh = channel as TelegramChannel; + for (const tc of toolCalls) { + await tgCh.sendToolFeedback(tc.toolName, tc.args as Record, msg.channelId).catch(() => {}); + } + if (toolResults) { + for (let i = 0; i < toolResults.length; i++) { + const tr = toolResults[i] as any; + const tcName = toolCalls[i]?.toolName as string | undefined; + if (tcName) { + await tgCh.sendStepDone(tcName, tr.result ?? tr, msg.channelId).catch(() => {}); + } + } + } + } else { + await channel.send(` [Using: ${names}]`, msg.channelId).catch(() => {}); + } + } + } else if (toolResults === undefined || (toolCalls === undefined)) { + const stepText = (toolResults as any)?.text ?? ''; + if (stepText) { + loopDetector.recordStepText(String(stepText)); + } + const noActionLoop = loopDetector.recordNoActionResult(); + if (noActionLoop) { + logger.warn('Reasoning loop detected — model keeps thinking without acting, aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send('⚠ I\'m stuck in a reasoning loop (thinking without taking action). Stopping.', msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + return; + } + const textRepeat = loopDetector.detectTextRepetition(); + if (textRepeat) { + logger.warn({ pattern: textRepeat.pattern, count: textRepeat.count }, 'Text repetition loop detected — aborting'); + if (!loopWarningSent && channel && msg.channelType !== 'internal') { + loopWarningSent = true; + await channel.send('⚠ I keep generating the same response. Stopping to prevent repetition.', msg.channelId).catch(() => {}); + } + loopAbortController.abort(); + } + } + }, + }); + } + + usedProvider = { name: provider.name, model: provider.getModel() }; + this.providers.markSuccess(provider.name); + break; + } catch (err: any) { + if (loopDetector.isHardAborted() || loopAbortController.signal.aborted) { + logger.info('Generation aborted due to loop detection — using partial response'); + if (!result && streamedText) { + result = { text: streamedText, usage: undefined }; + } + if (!result) { + result = { text: 'I stopped because I detected I was stuck in a loop (repeating the same action without progress). I cannot complete this task as requested. Please let me know if you\'d like me to try a completely different approach, or if there\'s something else I can help with.', usage: undefined }; + } + if (usedProvider) { + this.providers.markSuccess(usedProvider.name); + } + break; + } + lastError = err; + logger.warn({ provider: provider.name, err: err.message }, 'Provider failed, trying fallback'); + if (channel && msg.channelType !== 'internal') { + await channel.send(` [Provider ${provider.name} failed, trying fallback...]`, msg.channelId).catch(() => {}); + } + } + } + + if (!result) { + const errMsg = `All LLM providers failed. Last error: ${lastError?.message || 'unknown'}`; + logger.error({ err: lastError }, errMsg); + if (channel && msg.channelType !== 'internal') { + await channel.send(errMsg, msg.channelId); + } + this.lifecycle.transition('idle'); + return; + } + + const finalText = (streamedText || result.text || '').trim() || '(no text response)'; + + this.tokenBudget.recordUsage({ + provider: usedProvider!.name, + model: usedProvider!.model, + inputTokens: result.usage?.promptTokens ?? 0, + outputTokens: result.usage?.completionTokens ?? 0, + totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0), + channelType: msg.channelType, + }); + + this.shortTerm.add(msg.channelId, { + id: msg.id, + timestamp: msg.timestamp, + role: 'user', + content: msg.content, + }); + + this.shortTerm.add(msg.channelId, { + id: Date.now().toString(36), + timestamp: Date.now(), + role: 'assistant', + content: finalText, + tokenCount: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0), + }); + + this.episodic.record({ + type: 'message', + summary: `User: ${msg.content.slice(0, 100)} | Agent: ${finalText.slice(0, 100)}`, + channelType: msg.channelType, + }); + + if (msg.channelType !== 'internal') { + this.extractMemory(msg.content, finalText).catch(err => { + logger.warn({ err }, 'Memory extraction failed'); + }); + } + + if (channel && msg.channelType !== 'internal') { + const elapsed = Date.now() - startTime; + if (streamedText && streamedText.trim()) { + logger.info({ channelType: msg.channelType, elapsed }, 'Streamed response completed'); + } else { + logger.info({ channelType: msg.channelType, targetId: msg.channelId }, 'Sending response'); + await channel.send(finalText, msg.channelId, elapsed); + } + } else { + logger.debug('Internal prompt processed, no channel response needed'); + } + + this.lifecycle.transition('idle'); + } catch (err) { + logger.error({ err }, 'Error handling message'); + this.lifecycle.transition('idle'); + } finally { + if (isInternal || isScheduled) { + this.capabilities.permissions.setAutoApproveAll(false); + } + this.capabilities.permissions.clearElevation(); + } + } + + private buildSystemPrompt(): string { + let prompt = this.identity.getSystemPrompt(this.config.identity); + const skillContext = this.capabilities.getSkillContext(); + if (skillContext) { + prompt += '\n\n' + skillContext; + } + const budgetStatus = this.tokenBudget.getStatusText(); + prompt += '\n\n' + budgetStatus; + if (this.tokenBudget.getUsagePercentage() > 70) { + prompt += '\nBe concise to conserve tokens.'; + } + + prompt += `\n\nEnvironment:\n- Platform: ${process.platform}\n- Working directory: ${this.capabilities.getCwd()}`; + + if (this.userMemory) { + const summary = this.userMemory.getSummary(); + prompt += `\n\nSecond Brain is ENABLED. You have a persistent, structured memory of ${summary.total} facts about this user.`; + prompt += `\nMemory types: identity, preference, goal, project, habit, decision, constraint, relationship, episode, reflection.`; + prompt += `\nRelevant memories are automatically injected before each message. You can reference them naturally (e.g. "I remember you prefer TypeScript").`; + prompt += `\nUsers can manage memory with: /memory (overview, search, pause learning, clear).`; + if (summary.learningPaused) { + prompt += `\nLearning is currently PAUSED — no new memories will be extracted from conversations until resumed.`; + } + } else { + prompt += '\n\nSecond Brain is DISABLED. Basic long-term memory (text search over facts) is still active.'; + } + + const toolNames = this.capabilities.getToolNames(); + const githubTools = ['create_pr', 'review_pr', 'list_issues', 'create_issue', 'github_api']; + const hasGitHub = githubTools.some(t => toolNames.includes(t)); + if (hasGitHub) { + let githubHint = '\n\nGitHub companion is active.'; + const { defaultOwner, defaultRepo } = this.config.github; + if (defaultOwner && defaultRepo) { + githubHint += ` Default repo: ${defaultOwner}/${defaultRepo}. Use this when the user doesn't specify a repo.`; + } + + githubHint += ` + +Available GitHub tools and when to use them: +- git_add, git_commit, git_push: LOCAL git operations (stage, commit, push to a remote you have SSH/auth access to). All commits include "Co-authored-by: Mercury ". +- create_pr: Create a pull request on GitHub. The head branch must already exist on the remote. +- review_pr: Get PR details and optionally post a review comment. +- list_issues, create_issue: Browse and file issues. +- github_api: Raw GitHub API access. IMPORTANT USE CASES: + - Push files directly to GitHub via PUT /repos/{owner}/{repo}/contents/{path} when git push fails due to auth. The body must include "message" and "content" (base64-encoded file content). This creates a commit on GitHub with Mercury as co-author. + - Delete files via DELETE /repos/{owner}/{repo}/contents/{path} with a "message" and "sha" in the body. + - Any other GitHub API operation not covered by the other tools. + +When the user asks to "push to GitHub" or "upload files" and git push fails, use github_api with PUT /repos/{owner}/{repo}/contents/{path} to push content directly through the API. This bypasses local git entirely. + +Always specify owner and repo parameters on GitHub tools. The user's GitHub username is ${this.config.github.username || 'not set'}.'`; + + prompt += githubHint; + } + return prompt; + } + + async processInternalPrompt(prompt: string, channelId?: string, channelType?: string): Promise { + const syntheticMsg: ChannelMessage = { + id: `internal-${Date.now().toString(36)}`, + channelId: channelId || 'internal', + channelType: (channelType || 'internal') as ChannelType, + senderId: 'system', + content: prompt, + timestamp: Date.now(), + }; + this.enqueueMessage(syntheticMsg); + } + + private async handleScheduledTask(manifest: ScheduledTaskManifest): Promise { + logger.info({ task: manifest.id, channel: manifest.sourceChannelType }, 'Processing scheduled task'); + try { + let prompt = manifest.prompt || ''; + if (manifest.skillName) { + const skillHint = `Invoke the skill "${manifest.skillName}" using the use_skill tool and follow its instructions.`; + prompt = prompt ? `${prompt} ${skillHint}` : `Scheduled task triggered. ${skillHint}`; + } + if (!prompt) { + prompt = `Execute scheduled task: ${manifest.description}`; + } + await this.processInternalPrompt(prompt, manifest.sourceChannelId, manifest.sourceChannelType); + } catch (err) { + logger.error({ err, task: manifest.id }, 'Scheduled task execution failed'); + } + } + + private async heartbeat(): Promise { + logger.debug('Heartbeat tick'); + + const pruned = this.episodic.prune(7); + if (pruned > 0) { + logger.info({ pruned }, 'Episodic memory pruned'); + } + + if (this.userMemory) { + try { + const consolidation = this.userMemory.consolidate(); + if (consolidation.profileUpdated || consolidation.reflectionCount > 0) { + logger.info({ consolidation }, 'Second brain consolidated'); + } + + const pruning = this.userMemory.prune(); + if (pruning.activePruned > 0 || pruning.durablePruned > 0 || pruning.promoted > 0) { + logger.info({ pruning }, 'Second brain pruned'); + } + } catch (err) { + logger.warn({ err }, 'Second brain heartbeat error'); + } + } + + const notifications: string[] = []; + + const usagePct = this.tokenBudget.getUsagePercentage(); + if (usagePct >= 80) { + notifications.push(`Token budget at ${Math.round(usagePct)}% — ${this.tokenBudget.getRemaining().toLocaleString()} tokens remaining today.`); + } + + const pendingSchedules = this.scheduler.getManifests(); + const now = Date.now(); + for (const task of pendingSchedules) { + if (task.delaySeconds && task.executeAt) { + const executeAt = new Date(task.executeAt).getTime(); + const diffMin = Math.round((executeAt - now) / 60000); + if (diffMin > 0 && diffMin <= 5) { + notifications.push(`Task "${task.description}" fires in ${diffMin} minute${diffMin !== 1 ? 's' : ''}.`); + } + } + } + + if (notifications.length > 0) { + const channel = this.channels.getNotificationChannel(); + if (channel) { + const msg = notifications.join('\n'); + try { + await channel.send(msg, 'notification'); + } catch (err) { + logger.warn({ err }, 'Failed to send heartbeat notification'); + } + } + } + } + + private async extractMemory(userMessage: string, agentResponse: string): Promise { + if (!this.userMemory) return; + if (this.userMemory.isLearningPaused()) return; + + const trivial = /^(hi|hello|hey|thanks|thank you|ok|okay|yes|no|bye|goodbye|good morning|good evening)\b/i; + if (trivial.test(userMessage.trim())) return; + + if (!this.tokenBudget.canAfford(800)) return; + + try { + const provider = this.providers.getDefault(); + const result = await generateText({ + model: provider.getModelInstance(), + system: `You extract structured memory from conversations. Read the conversation and output a JSON array of memory candidates. Each candidate has: type (one of: identity, preference, goal, project, habit, decision, constraint, relationship, episode), summary (concise fact, 12-220 chars), detail (optional longer explanation), evidenceKind (direct for explicitly stated facts, inferred for patterns you notice), confidence (0.0-1.0), importance (0.0-1.0), durability (0.0-1.0). Extract 0-3 candidates. Only extract specific, durable, user-specific information. Do NOT extract trivial observations, greetings, or assistant behavior. Output pure JSON array, no markdown.`, + messages: [ + { role: 'user', content: `User: ${userMessage}\nAssistant: ${agentResponse}` }, + ], + maxTokens: 400, + }); + + this.tokenBudget.recordUsage({ + provider: provider.name, + model: provider.getModel(), + inputTokens: result.usage?.promptTokens ?? 0, + outputTokens: result.usage?.completionTokens ?? 0, + totalTokens: (result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0), + channelType: 'internal', + }); + + const text = result.text.trim(); + if (!text) return; + + let candidates: Array<{ + type: string; + summary: string; + detail?: string; + evidenceKind?: string; + confidence: number; + importance: number; + durability: number; + }>; + + try { + const jsonStr = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + candidates = JSON.parse(jsonStr); + } catch { + const facts = text + .split('\n') + .map(l => l.replace(/^-\s*/, '').trim()) + .filter(f => f.length > 10 && f.length < 200); + candidates = facts.slice(0, 3).map(f => ({ + type: 'preference', + summary: f, + confidence: 0.75, + importance: 0.7, + durability: 0.7, + evidenceKind: 'inferred', + })); + } + + const validTypes = ['identity', 'preference', 'goal', 'project', 'habit', 'decision', 'constraint', 'relationship', 'episode']; + const typed = candidates + .filter(c => c.summary && c.summary.length >= 12 && c.summary.length <= 220) + .filter(c => validTypes.includes(c.type)) + .map(c => ({ + type: c.type as any, + summary: c.summary, + detail: c.detail, + evidenceKind: (c.evidenceKind === 'direct' ? 'direct' : 'inferred') as 'direct' | 'inferred', + confidence: Math.min(1, Math.max(0, c.confidence ?? 0.7)), + importance: Math.min(1, Math.max(0, c.importance ?? 0.7)), + durability: Math.min(1, Math.max(0, c.durability ?? 0.7)), + })); + + if (typed.length > 0) { + const remembered = this.userMemory.remember(typed, 'conversation'); + if (remembered.length > 0) { + logger.info({ count: remembered.length, types: remembered.map(r => r.type) }, 'Second brain memories stored'); + } + } + } catch (err) { + logger.warn({ err }, 'Memory extraction error'); + } + } + + async shutdown(): Promise { + await this.sleep(); + logger.info('Mercury has shut down'); + } + + private async handleBudgetOverrideCLI(channel: import('../channels/base.js').Channel, msg: ChannelMessage): Promise { + const status = this.tokenBudget.getStatusText(); + await channel.send( + `Token budget exceeded! ${status}\n\nChoose an option:\n 1 — Override (allow this one request)\n 2 — Reset usage to zero\n 3 — Set a new daily budget (current: ${this.tokenBudget.getBudget().toLocaleString()})\n 4 — Cancel\n\nOr use /budget override, /budget reset, /budget set anytime.`, + msg.channelId, + ); + } + + async handleBudgetCommand(subcommand: string, channelType: string, channelId: string): Promise { + const channel = this.channels.get(channelType as any); + if (!channel) return; + + const parts = subcommand.trim().split(/\s+/); + const action = parts[0]?.toLowerCase(); + + if (action === 'override' || action === '1') { + this.tokenBudget.forceAllowNext(); + await channel.send('Budget override applied — your next request will proceed.', channelId); + } else if (action === 'reset' || action === '2') { + this.tokenBudget.resetUsage(); + await channel.send(`Usage reset to zero. ${this.tokenBudget.getStatusText()}`, channelId); + } else if (action === 'set' || action === '3') { + const newBudget = parseInt(parts[1], 10); + if (isNaN(newBudget) || newBudget <= 0) { + await channel.send('Please specify the new budget. Usage: `/budget set 100000` or type e.g. `3 100000`', channelId); + return; + } + this.tokenBudget.setBudget(newBudget); + await channel.send(`Daily budget updated to ${newBudget.toLocaleString()} tokens. ${this.tokenBudget.getStatusText()}`, channelId); + } else if (action === 'cancel' || action === '4') { + await channel.send(`Cancelled. ${this.tokenBudget.getStatusText()}`, channelId); + } else if (!action || action === 'status') { + await channel.send(this.tokenBudget.getStatusText(), channelId); + } else { + await channel.send(`Unknown budget command "${action}". Available: /budget, /budget override, /budget reset, /budget set , /budget status`, channelId); + } + } + + private async handleChatCommand(content: string, channelType: string, channelId: string): Promise { + const trimmed = content.trim(); + const cmd = trimmed.toLowerCase(); + const channel = this.channels.get(channelType as any); + if (!channel) return false; + + const ctx = this.capabilities.getChatCommandContext(); + if (!ctx) return false; + + if (cmd === '/help') { + await channel.send(ctx.manual(), channelId); + return true; + } + + if (cmd === '/status') { + const config = ctx.config(); + const budget = ctx.tokenBudget(); + const lines = [ + `**${config.identity.name}** — Status`, + `Owner: ${config.identity.owner || '(not set)'}`, + `Provider: ${config.providers.default}`, + `Telegram: ${config.channels.telegram.enabled ? 'enabled' : 'disabled'}`, + `Telegram access: ${getTelegramAccessSummary(config)}`, + `Budget: ${budget.getStatusText()}`, + `Skills: ${ctx.skillNames().length > 0 ? ctx.skillNames().join(', ') : 'none'}`, + ]; + await channel.send(lines.join('\n'), channelId); + return true; + } + + if (cmd === '/memory') { + if (!this.userMemory) { + await channel.send('Second brain is not enabled.', channelId); + return true; + } + + if (channelType === 'cli' && channel instanceof CLIChannel) { + await this.openCliMemoryMenu(channel, channelId); + return true; + } + + await this.sendMemoryOverview(channel, channelId); + return true; + } + + if (cmd.startsWith('/telegram')) { + if (channelType !== 'cli') { + await channel.send('`/telegram` is only available from the Mercury CLI chat.', channelId); + return true; + } + + const config = ctx.config(); + const rawSubcommand = trimmed.slice('/telegram'.length).trim(); + if (!rawSubcommand && channel instanceof CLIChannel) { + await channel.withMenu(async (select) => { + await this.openCliTelegramMenu(channel, channelId, select); + }); + return true; + } + + const parts = rawSubcommand.split(/\s+/).filter(Boolean); + const action = parts[0]?.toLowerCase() || 'help'; + const formatTelegramUser = (user: { + userId: number; + username?: string; + firstName?: string; + pairingCode?: string; + }) => { + const username = user.username ? ` (@${user.username})` : ''; + const firstName = user.firstName ? ` ${user.firstName}` : ''; + const pairingCode = user.pairingCode ? ` [code: ${user.pairingCode}]` : ''; + return `${user.userId}${username}${firstName}${pairingCode}`; + }; + + const sendTelegramOverview = async () => { + const lines = [ + '**Telegram Management**', + '', + `Access: ${getTelegramAccessSummary(config)}`, + `Admins: ${config.channels.telegram.admins.length > 0 ? config.channels.telegram.admins.map(formatTelegramUser).join(', ') : 'none'}`, + `Members: ${config.channels.telegram.members.length > 0 ? config.channels.telegram.members.map(formatTelegramUser).join(', ') : 'none'}`, + `Pending: ${config.channels.telegram.pending.length > 0 ? config.channels.telegram.pending.map(formatTelegramUser).join(', ') : 'none'}`, + '', + 'Commands:', + '• `/telegram pending`', + '• `/telegram users`', + '• `/telegram approve `', + '• `/telegram reject `', + '• `/telegram remove `', + '• `/telegram promote `', + '• `/telegram demote `', + '• `/telegram reset`', + ]; + await channel.send(lines.join('\n'), channelId); + }; + + if (action === 'help' || action === 'status') { + await sendTelegramOverview(); + return true; + } + + if (action === 'pending') { + const pending = getTelegramPendingRequests(config); + const lines = [ + '**Telegram Pending Requests**', + '', + pending.length > 0 ? pending.map(formatTelegramUser).join('\n') : 'No pending Telegram requests.', + ]; + await channel.send(lines.join('\n'), channelId); + return true; + } + + if (action === 'users') { + const approved = getTelegramApprovedUsers(config); + const lines = [ + '**Telegram Approved Users**', + '', + `Admins: ${config.channels.telegram.admins.length > 0 ? config.channels.telegram.admins.map(formatTelegramUser).join(', ') : 'none'}`, + `Members: ${config.channels.telegram.members.length > 0 ? config.channels.telegram.members.map(formatTelegramUser).join(', ') : 'none'}`, + '', + `Total approved: ${approved.length}`, + ]; + await channel.send(lines.join('\n'), channelId); + return true; + } + + if (action === 'approve') { + const value = parts[1]; + if (!value) { + await channel.send('Usage: `/telegram approve `', channelId); + return true; + } + + let approved = approveTelegramPendingRequestByPairingCode(config, value); + let resultLabel = value; + + if (!approved) { + const userId = Number(value); + if (!isNaN(userId)) { + approved = approveTelegramPendingRequest(config, userId, 'member'); + resultLabel = userId.toString(); + } + } + + if (!approved) { + await channel.send(`No pending Telegram request found for \`${resultLabel}\`.`, channelId); + return true; + } + + saveConfig(config); + await channel.send(`Approved Telegram user ${formatTelegramUser(approved)}.`, channelId); + return true; + } + + if (action === 'reject') { + const value = Number(parts[1]); + if (isNaN(value)) { + await channel.send('Usage: `/telegram reject `', channelId); + return true; + } + + const rejected = rejectTelegramPendingRequest(config, value); + if (!rejected) { + await channel.send(`No pending Telegram request found for \`${value}\`.`, channelId); + return true; + } + + saveConfig(config); + await channel.send(`Rejected Telegram request for ${formatTelegramUser(rejected)}.`, channelId); + return true; + } + + if (action === 'remove') { + const value = Number(parts[1]); + if (isNaN(value)) { + await channel.send('Usage: `/telegram remove `', channelId); + return true; + } + + const removed = removeTelegramUser(config, value); + if (!removed) { + await channel.send(`No approved Telegram user found for \`${value}\`.`, channelId); + return true; + } + + saveConfig(config); + await channel.send(`Removed Telegram access for ${formatTelegramUser(removed)}.`, channelId); + return true; + } + + if (action === 'promote') { + const value = Number(parts[1]); + if (isNaN(value)) { + await channel.send('Usage: `/telegram promote `', channelId); + return true; + } + + const promoted = promoteTelegramUserToAdmin(config, value); + if (!promoted) { + await channel.send(`No Telegram member found for \`${value}\`.`, channelId); + return true; + } + + saveConfig(config); + await channel.send(`Promoted ${formatTelegramUser(promoted)} to Telegram admin.`, channelId); + return true; + } + + if (action === 'demote') { + const value = Number(parts[1]); + if (isNaN(value)) { + await channel.send('Usage: `/telegram demote `', channelId); + return true; + } + + const demoted = demoteTelegramAdmin(config, value); + if (!demoted) { + await channel.send('Could not demote that Telegram admin. Mercury must keep at least one admin.', channelId); + return true; + } + + saveConfig(config); + await channel.send(`Demoted ${formatTelegramUser(demoted)} to Telegram member.`, channelId); + return true; + } + + if (action === 'reset' || action === 'unpair') { + config.channels.telegram.admins = []; + config.channels.telegram.members = []; + config.channels.telegram.pending = []; + saveConfig(config); + await channel.send('Telegram access reset. New users can send /start to begin pairing again.', channelId); + return true; + } + + await channel.send( + `Unknown Telegram command "${action}". Try \`/telegram\`, \`/telegram pending\`, or \`/telegram users\`.`, + channelId, + ); + return true; + } + + if ((cmd === '/' || cmd === '/menu') && channelType === 'cli' && channel instanceof CLIChannel) { + await this.openCliCommandMenu(channel, channelId); + return true; + } + + if (cmd === '/tools') { + const tools = ctx.toolNames(); + const grouped = [ + `**${tools.length} tools loaded:**`, + '', + ...tools.sort().map(t => `• \`${t}\``), + ]; + await channel.send(grouped.join('\n'), channelId); + return true; + } + + if (cmd === '/skills') { + const names = ctx.skillNames(); + if (names.length === 0) { + await channel.send('No skills installed. Ask me to "install skill from " to add one.', channelId); + } else { + const lines = [ + `**${names.length} skill${names.length > 1 ? 's' : ''} installed:**`, + '', + ...names.map(n => `• ${n}`), + ]; + await channel.send(lines.join('\n'), channelId); + } + return true; + } + + if (cmd === '/stream on') { + this.telegramStreaming = true; + await channel.send('Telegram streaming enabled. Responses will appear progressively.', channelId); + return true; + } + + if (cmd === '/stream off') { + this.telegramStreaming = false; + await channel.send('Telegram streaming disabled. Responses will arrive as a single message.', channelId); + return true; + } + + if (cmd === '/stream') { + this.telegramStreaming = !this.telegramStreaming; + await channel.send( + this.telegramStreaming + ? 'Telegram streaming enabled. Responses will appear progressively.' + : 'Telegram streaming disabled. Responses will arrive as a single message.', + channelId, + ); + return true; + } + if (cmd === '/stream off') { + this.telegramStreaming = false; + await channel.send('Telegram streaming disabled. Responses will arrive as a single message.', channelId); + return true; + } + + return false; + } + + private async openCliCommandMenu(channel: CLIChannel, channelId: string): Promise { + const ctx = this.capabilities.getChatCommandContext(); + if (!ctx) return; + + await channel.withMenu(async (select) => { + while (true) { + const streamLabel = this.telegramStreaming ? 'Disable Telegram Streaming' : 'Enable Telegram Streaming'; + const action = await select('Mercury Commands', [ + { value: 'status', label: 'Status' }, + { value: 'memory', label: 'Memory' }, + { value: 'telegram', label: 'Telegram' }, + { value: 'tools', label: 'Tools' }, + { value: 'skills', label: 'Skills' }, + { value: 'stream', label: streamLabel }, + { value: 'help', label: 'Help' }, + { value: 'exit', label: 'Exit' }, + ]); + + if (action === 'exit') { + return; + } + + if (action === 'status') { + await this.handleChatCommand('/status', 'cli', channelId); + continue; + } + + if (action === 'memory') { + if (this.userMemory) { + await this.openCliMemoryMenu(channel, channelId, select); + } else { + await channel.send('Second brain is not enabled.', channelId); + } + continue; + } + + if (action === 'telegram') { + await this.openCliTelegramMenu(channel, channelId, select); + continue; + } + + if (action === 'tools') { + await this.handleChatCommand('/tools', 'cli', channelId); + continue; + } + + if (action === 'skills') { + await this.handleChatCommand('/skills', 'cli', channelId); + continue; + } + + if (action === 'stream') { + await this.handleChatCommand('/stream', 'cli', channelId); + continue; + } + + if (action === 'help') { + await channel.send(ctx.manual(), channelId); + } + } + }); + } + + private async sendMemoryOverview(channel: any, channelId: string): Promise { + if (!this.userMemory) return; + const summary = this.userMemory.getSummary(); + const lines = [ + `**Memory Overview**`, + `Total memories: ${summary.total}`, + `Learning: ${summary.learningPaused ? 'PAUSED' : 'ACTIVE'}`, + ]; + if (summary.profileSummary) { + lines.push(`Profile: ${summary.profileSummary}`); + } + if (summary.activeSummary) { + lines.push(`Active: ${summary.activeSummary}`); + } + const typeEntries = Object.entries(summary.byType); + if (typeEntries.length > 0) { + lines.push(''); + lines.push('By type:'); + for (const [type, count] of typeEntries) { + lines.push(` ${type}: ${count}`); + } + } + await channel.send(lines.join('\n'), channelId); + } + + private async openCliMemoryMenu(channel: CLIChannel, channelId: string, select?: (title: string, options: ArrowSelectOption[]) => Promise): Promise { + if (!this.userMemory) return; + + const runMenu = async (sel: (title: string, options: ArrowSelectOption[]) => Promise) => { + while (true) { + const learningLabel = this.userMemory!.isLearningPaused() ? 'Resume Learning' : 'Pause Learning'; + const action = await sel('Memory', [ + { value: 'overview', label: 'Overview' }, + { value: 'recent', label: 'Recent Memories' }, + { value: 'search', label: 'Search' }, + { value: 'toggle', label: learningLabel }, + { value: 'clear', label: 'Clear All Memories' }, + { value: 'back', label: 'Back' }, + ]); + + if (action === 'back') return; + + if (action === 'overview') { + await this.sendMemoryOverview(channel, channelId); + continue; + } + + if (action === 'recent') { + const recent = this.userMemory!.getRecent(10); + if (recent.length === 0) { + await channel.send('No memories yet.', channelId); + continue; + } + const lines = ['**Recent Memories:**', '']; + for (const r of recent) { + const scope = r.scope === 'active' ? '⏳' : '📌'; + const kind = r.evidenceKind === 'direct' ? 'direct' : r.evidenceKind === 'inferred' ? 'inferred' : r.evidenceKind; + lines.push(`${scope} [${r.type}] ${r.summary}`); + lines.push(` Confidence: ${r.confidence.toFixed(2)} | Evidence: ${kind} | Seen: ${r.evidenceCount}x`); + } + await channel.send(lines.join('\n'), channelId); + continue; + } + + if (action === 'search') { + const query = await channel.prompt('Search memories: '); + if (!query) continue; + const results = this.userMemory!.search(query, 10); + if (results.length === 0) { + await channel.send(`No memories found matching "${query}".`, channelId); + continue; + } + const lines = [`**Search results for "${query}":**`, '']; + for (const r of results) { + const scope = r.scope === 'active' ? '⏳' : '📌'; + lines.push(`${scope} [${r.type}] ${r.summary}`); + lines.push(` Confidence: ${r.confidence.toFixed(2)} | Evidence: ${r.evidenceKind} | Seen: ${r.evidenceCount}x`); + } + await channel.send(lines.join('\n'), channelId); + continue; + } + + if (action === 'toggle') { + const currentlyPaused = this.userMemory!.isLearningPaused(); + this.userMemory!.setLearningPaused(!currentlyPaused); + await channel.send(currentlyPaused ? 'Learning resumed. Mercury will remember new things from conversations.' : 'Learning paused. Mercury will not store new memories until resumed.', channelId); + continue; + } + + if (action === 'clear') { + const confirm = await sel('Clear all memories?', [ + { value: 'cancel', label: 'Cancel' }, + { value: 'confirm', label: 'Clear everything' }, + ]); + if (confirm === 'confirm') { + const cleared = this.userMemory!.clear(); + await channel.send(`Cleared ${cleared} memories.`, channelId); + } + continue; + } + } + }; + + if (select) { + await runMenu(select); + } else { + await channel.withMenu(runMenu); + } + } + + private async openCliTelegramMenu( + channel: CLIChannel, + channelId: string, + select: (title: string, options: ArrowSelectOption[]) => Promise, + ): Promise { + const ctx = this.capabilities.getChatCommandContext(); + if (!ctx) return; + const formatTelegramUser = (user: { + userId: number; + username?: string; + firstName?: string; + pairingCode?: string; + }) => { + const username = user.username ? ` (@${user.username})` : ''; + const firstName = user.firstName ? ` ${user.firstName}` : ''; + const pairingCode = user.pairingCode ? ` [code: ${user.pairingCode}]` : ''; + return `${user.userId}${username}${firstName}${pairingCode}`; + }; + + const selectFromUsers = async ( + title: string, + users: Array<{ userId: number; username?: string; firstName?: string; pairingCode?: string }>, + emptyMessage: string, + backValue: string = 'back', + ): Promise => { + if (users.length === 0) { + await channel.send(emptyMessage, channelId); + return backValue; + } + + return select(title, [ + ...users.map((user) => ({ + value: user.pairingCode || user.userId.toString(), + label: formatTelegramUser(user), + })), + { value: backValue, label: 'Back' }, + ]); + }; + + while (true) { + const config = ctx.config(); + const action = await select('Telegram Commands', [ + { value: 'overview', label: 'Overview' }, + { value: 'pending', label: `Pending Requests (${config.channels.telegram.pending.length})` }, + { value: 'users', label: `Approved Users (${getTelegramApprovedUsers(config).length})` }, + { value: 'approve', label: 'Approve Request' }, + { value: 'reject', label: 'Reject Request' }, + { value: 'remove', label: 'Remove User' }, + { value: 'promote', label: 'Promote to Admin' }, + { value: 'demote', label: 'Demote Admin' }, + { value: 'reset', label: 'Reset Telegram Access' }, + { value: 'back', label: 'Back' }, + { value: 'exit', label: 'Exit' }, + ]); + + if (action === 'exit') { + return; + } + + if (action === 'back') { + return; + } + + if (action === 'overview') { + await this.handleChatCommand('/telegram status', 'cli', channelId); + continue; + } + + if (action === 'pending') { + await this.handleChatCommand('/telegram pending', 'cli', channelId); + continue; + } + + if (action === 'users') { + await this.handleChatCommand('/telegram users', 'cli', channelId); + continue; + } + + if (action === 'approve') { + const pending = getTelegramPendingRequests(config); + const selected = await selectFromUsers( + 'Approve Telegram Request', + pending, + 'There are no pending Telegram requests to approve.', + ); + + if (selected === 'back') { + continue; + } + + await this.handleChatCommand(`/telegram approve ${selected}`, 'cli', channelId); + continue; + } + + if (action === 'reject') { + const pending = getTelegramPendingRequests(config); + const selected = await selectFromUsers( + 'Reject Telegram Request', + pending, + 'There are no pending Telegram requests to reject.', + ); + + if (selected === 'back') { + continue; + } + + const request = pending.find((entry) => (entry.pairingCode || entry.userId.toString()) === selected); + if (!request) { + await channel.send('That Telegram request is no longer pending.', channelId); + continue; + } + + await this.handleChatCommand(`/telegram reject ${request.userId}`, 'cli', channelId); + continue; + } + + if (action === 'remove') { + const approved = getTelegramApprovedUsers(config); + const selected = await selectFromUsers( + 'Remove Telegram User', + approved, + 'There are no approved Telegram users to remove.', + ); + + if (selected === 'back') { + continue; + } + + const user = approved.find((entry) => entry.userId.toString() === selected); + if (!user) { + await channel.send('That Telegram user is no longer approved.', channelId); + continue; + } + + await this.handleChatCommand(`/telegram remove ${user.userId}`, 'cli', channelId); + continue; + } + + if (action === 'promote') { + const members = config.channels.telegram.members; + const selected = await selectFromUsers( + 'Promote Telegram Member', + members, + 'There are no Telegram members available to promote.', + ); + + if (selected === 'back') { + continue; + } + + const member = members.find((entry) => entry.userId.toString() === selected); + if (!member) { + await channel.send('That Telegram member is no longer available.', channelId); + continue; + } + + await this.handleChatCommand(`/telegram promote ${member.userId}`, 'cli', channelId); + continue; + } + + if (action === 'demote') { + const admins = config.channels.telegram.admins; + const selected = await selectFromUsers( + 'Demote Telegram Admin', + admins, + 'There are no Telegram admins available to demote.', + ); + + if (selected === 'back') { + continue; + } + + const admin = admins.find((entry) => entry.userId.toString() === selected); + if (!admin) { + await channel.send('That Telegram admin is no longer available.', channelId); + continue; + } + + await this.handleChatCommand(`/telegram demote ${admin.userId}`, 'cli', channelId); + continue; + } + + if (action === 'reset') { + const confirmation = await select('Reset Telegram Access?', [ + { value: 'cancel', label: 'Cancel' }, + { value: 'confirm', label: 'Reset all Telegram access' }, + { value: 'back', label: 'Back' }, + ]); + + if (confirmation === 'confirm') { + clearTelegramAccess(config); + saveConfig(config); + await channel.send('Telegram access reset. New users can send /start to begin pairing again.', channelId); + } + + continue; + } + } + } +} diff --git a/src/providers/claude-cli.ts b/src/providers/claude-cli.ts new file mode 100644 index 0000000..96011fb --- /dev/null +++ b/src/providers/claude-cli.ts @@ -0,0 +1,361 @@ +/** + * Claude CLI provider — rides the user's `claude` CLI OAuth session (Claude Max / + * Claude Pro) instead of requiring an Anthropic API key. + * + * Exposes a proper Vercel AI SDK v1 `LanguageModelV1` so it plugs into the + * agent's `streamText()` / `generateText()` loop the same way `@ai-sdk/anthropic` + * does. + * + * Tool-use limitation: the `claude -p` CLI cannot accept caller-defined tools — + * it only knows its own built-ins (Bash, Read, Edit, ...). We disable those + * with `--tools ""` so Claude never executes anything locally. Net effect: this + * adapter is TEXT-ONLY. If the caller passes `mode.tools`, we surface a warning + * and proceed without tool calls. Workflows that require tools (Mercury's + * scheduled skills) need a different provider (anthropic API / openai). + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { + LanguageModelV1, + LanguageModelV1CallOptions, + LanguageModelV1CallWarning, + LanguageModelV1FinishReason, + LanguageModelV1Prompt, + LanguageModelV1StreamPart, +} from '@ai-sdk/provider'; +import { BaseProvider, type LLMResponse, type LLMStreamChunk } from './base.js'; +import type { ProviderConfig } from '../utils/config.js'; + +interface ClaudeCliEvent { + type: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + is_error?: boolean; + result?: string; +} + +// Flatten AI SDK prompt messages → (system, transcript) for `claude -p`. +// System text goes to --append-system-prompt. Conversation goes on stdin. +function serializePrompt(prompt: LanguageModelV1Prompt): { system: string; userPrompt: string } { + const systemParts: string[] = []; + const transcript: string[] = []; + + for (const msg of prompt) { + if (msg.role === 'system') { + if (typeof msg.content === 'string' && msg.content) systemParts.push(msg.content); + continue; + } + + if (msg.role === 'user') { + const text = msg.content + .filter((p) => p.type === 'text') + .map((p) => (p as { text: string }).text) + .join(''); + if (text) transcript.push(`User: ${text}`); + continue; + } + + if (msg.role === 'assistant') { + const pieces: string[] = []; + for (const p of msg.content) { + if (p.type === 'text') pieces.push((p as { text: string }).text); + else if (p.type === 'tool-call') { + const tc = p as { toolName: string; args: unknown }; + pieces.push(`[Tool call: ${tc.toolName} args: ${JSON.stringify(tc.args)}]`); + } + } + const combined = pieces.join(''); + if (combined) transcript.push(`Assistant: ${combined}`); + continue; + } + + if (msg.role === 'tool') { + const res = msg.content + .map((p) => `[Tool result: ${JSON.stringify((p as { result: unknown }).result)}]`) + .join('\n'); + if (res) transcript.push(res); + } + } + + return { + system: systemParts.join('\n\n'), + userPrompt: transcript.join('\n\n'), + }; +} + +function buildClaudeArgs(modelId: string, system: string): string[] { + const args = [ + '-p', + '--output-format', 'stream-json', + '--verbose', + '--dangerously-skip-permissions', + '--no-session-persistence', + '--exclude-dynamic-system-prompt-sections', + // Disable ALL built-in tools so Claude never acts locally — we only want text back. + '--tools', '', + ]; + if (modelId) args.push('--model', modelId); + if (system) args.push('--append-system-prompt', system); + return args; +} + +function spawnClaude(cliPath: string, args: string[]): ChildProcessWithoutNullStreams { + return spawn(cliPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + shell: false, + }); +} + +function extractAssistantText(ev: ClaudeCliEvent): string { + if (ev.type !== 'assistant') return ''; + const parts = ev.message?.content ?? []; + return parts.filter((p) => p.type === 'text' && p.text).map((p) => p.text ?? '').join(''); +} + +function tokensFromResult(ev: ClaudeCliEvent): { promptTokens: number; completionTokens: number } { + const u = ev.usage ?? {}; + const promptTokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0); + const completionTokens = u.output_tokens ?? 0; + return { promptTokens, completionTokens }; +} + +function toolsWarningIfNeeded(options: LanguageModelV1CallOptions): LanguageModelV1CallWarning[] { + if (options.mode.type === 'regular' && options.mode.tools && options.mode.tools.length > 0) { + return [{ + type: 'other', + message: 'claudeCli provider is text-only — tools were ignored. Use `anthropic` (API key) or `openai` for tool-using workflows.', + }]; + } + return []; +} + +export class ClaudeCliModel implements LanguageModelV1 { + readonly specificationVersion = 'v1'; + readonly provider = 'claude-cli'; + readonly modelId: string; + readonly defaultObjectGenerationMode = undefined; + readonly supportsImageUrls = false; + readonly supportsStructuredOutputs = false; + + private cliPath: string; + + constructor(options: { modelId: string; cliPath: string }) { + this.modelId = options.modelId; + this.cliPath = options.cliPath; + } + + async doGenerate(options: LanguageModelV1CallOptions) { + const { system, userPrompt } = serializePrompt(options.prompt); + const args = buildClaudeArgs(this.modelId, system); + const warnings = toolsWarningIfNeeded(options); + + const child = spawnClaude(this.cliPath, args); + options.abortSignal?.addEventListener('abort', () => { try { child.kill('SIGTERM'); } catch { /* noop */ } }); + child.stdin.end(userPrompt); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (c: Buffer) => { stdout += c.toString('utf8'); }); + child.stderr.on('data', (c: Buffer) => { stderr += c.toString('utf8'); }); + + const exitCode: number = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 0)); + }); + + if (exitCode !== 0) { + throw new Error(`claude CLI exited ${exitCode}: ${stderr.slice(0, 500)}`); + } + + let text = ''; + let promptTokens = 0; + let completionTokens = 0; + let finishReason: LanguageModelV1FinishReason = 'stop'; + + for (const line of stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + let ev: ClaudeCliEvent; + try { ev = JSON.parse(trimmed); } catch { continue; } + + if (ev.type === 'assistant') { + text += extractAssistantText(ev); + } else if (ev.type === 'result') { + if (ev.is_error) { + throw new Error(`claude CLI result error: ${ev.result ?? 'unknown'}`); + } + ({ promptTokens, completionTokens } = tokensFromResult(ev)); + } + } + + return { + text, + finishReason, + usage: { promptTokens, completionTokens }, + rawCall: { rawPrompt: userPrompt, rawSettings: { model: this.modelId, system } }, + warnings, + }; + } + + async doStream(options: LanguageModelV1CallOptions) { + const { system, userPrompt } = serializePrompt(options.prompt); + const args = buildClaudeArgs(this.modelId, system); + const warnings = toolsWarningIfNeeded(options); + + const child = spawnClaude(this.cliPath, args); + options.abortSignal?.addEventListener('abort', () => { try { child.kill('SIGTERM'); } catch { /* noop */ } }); + child.stdin.end(userPrompt); + + let buffer = ''; + let prevText = ''; + let promptTokens = 0; + let completionTokens = 0; + let stderrBuf = ''; + let finishReason: LanguageModelV1FinishReason = 'stop'; + + const stream = new ReadableStream({ + start(controller) { + const consumeLine = (raw: string) => { + const trimmed = raw.trim(); + if (!trimmed) return; + let ev: ClaudeCliEvent; + try { ev = JSON.parse(trimmed); } catch { return; } + + if (ev.type === 'assistant') { + const full = extractAssistantText(ev); + if (full.length > prevText.length) { + controller.enqueue({ + type: 'text-delta', + textDelta: full.slice(prevText.length), + }); + prevText = full; + } + } else if (ev.type === 'result') { + if (ev.is_error) { + controller.enqueue({ type: 'error', error: new Error(`claude CLI: ${ev.result ?? 'unknown'}`) }); + finishReason = 'error'; + } + ({ promptTokens, completionTokens } = tokensFromResult(ev)); + } + }; + + child.stderr.on('data', (c: Buffer) => { stderrBuf += c.toString('utf8'); }); + + child.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) consumeLine(line); + }); + + // 'close' fires after stdio streams have drained — 'exit' can race with pending stdout data. + child.on('close', (code) => { + if (buffer.length > 0) { + consumeLine(buffer); + buffer = ''; + } + if (code !== 0 && finishReason !== 'error') { + controller.enqueue({ + type: 'error', + error: new Error(`claude CLI exited ${code}: ${stderrBuf.slice(0, 500)}`), + }); + finishReason = 'error'; + } + controller.enqueue({ + type: 'finish', + finishReason, + usage: { promptTokens, completionTokens }, + }); + controller.close(); + }); + + child.on('error', (err) => { + controller.enqueue({ type: 'error', error: err }); + controller.close(); + }); + }, + cancel() { + try { child.kill('SIGTERM'); } catch { /* noop */ } + }, + }); + + return { + stream, + rawCall: { rawPrompt: userPrompt, rawSettings: { model: this.modelId, system } }, + warnings, + }; + } +} + +export class ClaudeCliProvider extends BaseProvider { + readonly name = 'claudeCli'; + readonly model: string; + private cliPath: string; + + constructor(config: ProviderConfig) { + super(config); + this.model = config.model; + this.cliPath = (config.baseUrl && config.baseUrl.trim().length > 0) ? config.baseUrl : 'claude'; + } + + isAvailable(): boolean { + return true; + } + + getModelInstance(): LanguageModelV1 { + return new ClaudeCliModel({ modelId: this.model, cliPath: this.cliPath }); + } + + async generateText(prompt: string, systemPrompt: string): Promise { + const model = this.getModelInstance(); + const result = await model.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: [ + ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []), + { role: 'user' as const, content: [{ type: 'text' as const, text: prompt }] }, + ], + }); + + return { + text: result.text ?? '', + inputTokens: result.usage.promptTokens, + outputTokens: result.usage.completionTokens, + totalTokens: result.usage.promptTokens + result.usage.completionTokens, + model: this.model, + provider: this.name, + }; + } + + async *streamText(prompt: string, systemPrompt: string): AsyncIterable { + const model = this.getModelInstance(); + const { stream } = await model.doStream({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: [ + ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []), + { role: 'user' as const, content: [{ type: 'text' as const, text: prompt }] }, + ], + }); + + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value.type === 'text-delta') yield { text: value.textDelta, done: false }; + else if (value.type === 'finish') yield { text: '', done: true }; + else if (value.type === 'error') throw value.error; + } + } finally { + reader.releaseLock(); + } + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 140df08..8dd88bb 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,7 @@ export { BaseProvider } from './base.js'; export { OpenAICompatProvider } from './openai-compat.js'; export { AnthropicProvider } from './anthropic.js'; +export { ClaudeCliProvider } from './claude-cli.js'; export { OllamaProvider } from './ollama.js'; export { ProviderRegistry } from './registry.js'; export type { LLMResponse, LLMStreamChunk } from './base.js'; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index d6f3e10..07db3a2 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -3,6 +3,7 @@ import { isProviderConfigured } from '../utils/config.js'; import type { BaseProvider } from './base.js'; import { OpenAICompatProvider } from './openai-compat.js'; import { AnthropicProvider } from './anthropic.js'; +import { ClaudeCliProvider } from './claude-cli.js'; import { OllamaProvider } from './ollama.js'; import { logger } from '../utils/logger.js'; @@ -18,6 +19,7 @@ export class ProviderRegistry { config.providers.deepseek, config.providers.openai, config.providers.anthropic, + config.providers.claudeCli, config.providers.grok, config.providers.ollamaCloud, config.providers.ollamaLocal, @@ -29,6 +31,8 @@ export class ProviderRegistry { let provider: BaseProvider; if (pc.name === 'anthropic') { provider = new AnthropicProvider(pc); + } else if (pc.name === 'claudeCli') { + provider = new ClaudeCliProvider(pc); } else if (pc.name === 'ollamaCloud' || pc.name === 'ollamaLocal') { provider = new OllamaProvider(pc); } else { diff --git a/src/utils/config.ts b/src/utils/config.ts index 8cf99e3..a684054 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -49,6 +49,7 @@ export interface TelegramPendingRequest { export type ProviderName = | 'openai' | 'anthropic' + | 'claudeCli' | 'deepseek' | 'grok' | 'ollamaCloud' @@ -64,6 +65,7 @@ export interface MercuryConfig { default: ProviderName; openai: ProviderConfig; anthropic: ProviderConfig; + claudeCli: ProviderConfig; deepseek: ProviderConfig; grok: ProviderConfig; ollamaCloud: ProviderConfig; @@ -145,6 +147,15 @@ export function getDefaultConfig(): MercuryConfig { model: getEnv('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514'), enabled: getEnvBool('ANTHROPIC_ENABLED', true), }, + claudeCli: { + // Uses the local `claude` CLI and its OAuth session (Claude Max / Pro) + // instead of an API key. apiKey here is a dummy presence marker only. + name: 'claudeCli', + apiKey: getEnv('CLAUDE_CLI_APIKEY_MARKER', 'oauth'), + baseUrl: getEnv('CLAUDE_CLI_PATH', 'claude'), + model: getEnv('CLAUDE_CLI_MODEL', 'opus'), + enabled: getEnvBool('CLAUDE_CLI_ENABLED', false), + }, deepseek: { name: 'deepseek', apiKey: getEnv('DEEPSEEK_API_KEY', ''), @@ -277,6 +288,10 @@ export function isProviderConfigured(provider: ProviderConfig): boolean { if (provider.name === 'ollamaLocal') { return provider.baseUrl.length > 0 && provider.model.length > 0; } + if (provider.name === 'claudeCli') { + // Presence of CLI path + model is enough; auth comes from the CLI's own OAuth. + return provider.baseUrl.length > 0 && provider.model.length > 0; + } return provider.apiKey.length > 0; } diff --git a/src/utils/provider-models.ts b/src/utils/provider-models.ts index 30b3eb0..3aba05d 100644 --- a/src/utils/provider-models.ts +++ b/src/utils/provider-models.ts @@ -27,6 +27,12 @@ const ANTHROPIC_PREFERRED_MODELS = [ 'claude-3-5-haiku-latest', ] as const; +const CLAUDE_CLI_PREFERRED_MODELS = [ + 'opus', + 'sonnet', + 'haiku', +] as const; + const DEEPSEEK_PREFERRED_MODELS = [ 'deepseek-chat', 'deepseek-reasoner', @@ -151,6 +157,7 @@ function chooseRecommendedModel( deepseek: DEEPSEEK_PREFERRED_MODELS, openai: OPENAI_PREFERRED_MODELS, anthropic: ANTHROPIC_PREFERRED_MODELS, + claudeCli: CLAUDE_CLI_PREFERRED_MODELS, grok: GROK_PREFERRED_MODELS, ollamaCloud: OLLAMA_CLOUD_PREFERRED_MODELS, ollamaLocal: OLLAMA_LOCAL_PREFERRED_MODELS, @@ -184,6 +191,7 @@ export function buildModelCatalog( deepseek: DEEPSEEK_PREFERRED_MODELS, openai: OPENAI_PREFERRED_MODELS, anthropic: ANTHROPIC_PREFERRED_MODELS, + claudeCli: CLAUDE_CLI_PREFERRED_MODELS, grok: GROK_PREFERRED_MODELS, ollamaCloud: OLLAMA_CLOUD_PREFERRED_MODELS, ollamaLocal: OLLAMA_LOCAL_PREFERRED_MODELS,