diff --git a/package-lock.json b/package-lock.json index 4b0a5261..07b93d0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@blockrun/llm": "^2.0.0", "@colbymchenry/codegraph": "^0.9.7", "@modelcontextprotocol/sdk": "^1.29.0", + "@slack/bolt": "^4.7.3", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "@types/react": "^19.2.14", @@ -867,6 +868,122 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@slack/bolt": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.7.3.tgz", + "integrity": "sha512-bODs8q/yNDWUPoxmQhFrRqLMA5vhB/PDizYWqb6CkQhLWEUo5JFtfJcmeU4ElGl6qSt++OKjSYNa4MPc77CleQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/oauth": "^3.0.5", + "@slack/socket-mode": "^2.0.7", + "@slack/types": "^2.21.1", + "@slack/web-api": "^7.16.0", + "axios": "^1.12.0", + "express": "^5.0.0", + "path-to-regexp": "^8.1.0", + "raw-body": "^3", + "tsscmp": "^1.0.6" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + }, + "peerDependencies": { + "@types/express": "^5.0.0" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/oauth": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-3.0.5.tgz", + "integrity": "sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/jsonwebtoken": "^9", + "@types/node": ">=18", + "jsonwebtoken": "^9" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", + "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/socket-mode/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@slack/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.16.0.tgz", + "integrity": "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.16.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, "node_modules/@solana-program/compute-budget": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@solana-program/compute-budget/-/compute-budget-0.11.0.tgz", @@ -2259,6 +2376,17 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2268,6 +2396,54 @@ "@types/node": "*" } }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -2304,6 +2480,20 @@ "@types/node": "*" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT", + "peer": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2313,6 +2503,33 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -2448,6 +2665,18 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/agentkeepalive": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", @@ -2536,8 +2765,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/auto-bind": { "version": "5.0.1", @@ -2551,6 +2779,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -2700,6 +2940,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bufferutil": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", @@ -2947,7 +3193,6 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", - "optional": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3093,7 +3338,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=0.4.0" } @@ -3136,6 +3380,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3204,7 +3457,6 @@ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", - "optional": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -3445,12 +3697,31 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", - "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -3474,7 +3745,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.6" } @@ -3484,7 +3754,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "optional": true, "dependencies": { "mime-db": "1.52.0" }, @@ -3620,7 +3889,6 @@ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", - "optional": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -3672,6 +3940,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -3847,6 +4128,12 @@ "node": ">= 0.10" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -3883,6 +4170,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4014,6 +4313,49 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "license": "ISC" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -4026,6 +4368,48 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4301,6 +4685,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -4328,6 +4721,53 @@ "node": ">=8" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -4426,6 +4866,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -4546,6 +4995,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4993,6 +5451,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-fest": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", diff --git a/package.json b/package.json index 971c600c..c4fe81f4 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@blockrun/llm": "^2.0.0", "@colbymchenry/codegraph": "^0.9.7", "@modelcontextprotocol/sdk": "^1.29.0", + "@slack/bolt": "^4.7.3", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "@types/react": "^19.2.14", diff --git a/src/agent/llm.ts b/src/agent/llm.ts index fc5ca5f0..2aa4b540 100644 --- a/src/agent/llm.ts +++ b/src/agent/llm.ts @@ -520,7 +520,7 @@ export class ModelClient { * default model. */ private resolveVirtualModel(model: string): string { - if (!model.startsWith('blockrun/')) return model; + if (!model || !model.startsWith('blockrun/')) return model; try { const profile = parseRoutingProfile(model); @@ -563,6 +563,14 @@ export class ModelClient { // Reset the per-call charge tracker. signBasePayment / signSolanaPayment // will set it when the gateway demands a 402 settlement. this.lastPaidUsd = 0; + // Guard: a missing/non-string model (e.g. a flaky-gateway fallback that + // produced undefined) must not hard-crash with a cryptic + // "reading 'startsWith'". Normalize to the routing profile, which resolves + // to a concrete model below. + if (!request.model || typeof request.model !== 'string') { + console.error('[franklin] request.model was missing — defaulting to blockrun/auto'); + request = { ...request, model: 'blockrun/auto' }; + } // Resolve virtual models before any API call const resolvedModel = this.resolveVirtualModel(request.model); if (resolvedModel !== request.model) { diff --git a/src/agent/loop.ts b/src/agent/loop.ts index e9ff4e31..55f5d98d 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -4,7 +4,7 @@ */ import { ModelClient } from './llm.js'; -import { autoCompactIfNeeded, forceCompact, microCompact } from './compact.js'; +import { autoCompactIfNeeded, forceCompact, microCompact, projectCompactionSavings } from './compact.js'; import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor, getAnchoredTokenCount, getContextWindow, setEstimationModel } from './tokens.js'; import { handleSlashCommand } from './commands.js'; import { loadBundledSkills, getSkillVars } from '../skills/bootstrap.js'; @@ -1153,13 +1153,22 @@ export async function interactiveSession( // compacting (the compact itself runs on a cheaper model // and costs <$0.05). const TURN_COST_CAP_FOR_EARLY_COMPACT = 1.00; + // ROI gate: forceCompact (used below) has no savings check of its own, so + // without this it fires even on a tiny history and reports "saved 1%" — + // a wasted summarizer round-trip. Only compact when the projected savings + // clear the floor (≥20%), which a small history can never do. + // The ROI gate applies ONLY to the call-count trigger: the $1.00 cost cap + // is an emergency brake (see the 2026-05-11 note above) and must fire + // even when projected savings are low — gating it would reintroduce the + // $9.45 runaway it was added to stop. + const bloatTriggered = + (turnToolCalls > 15 && turnCostUsd > 0.03 && projectCompactionSavings(history).worthIt) || + turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT; if ( + config.costSaver !== false && !bloatCompactedThisTurn && compactFailures < 3 && - ( - (turnToolCalls > 15 && turnCostUsd > 0.03) || - turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT - ) + bloatTriggered ) { try { const beforeTokens = estimateHistoryTokens(history); diff --git a/src/agent/streaming-executor.ts b/src/agent/streaming-executor.ts index 34621dda..a9e537c5 100644 --- a/src/agent/streaming-executor.ts +++ b/src/agent/streaming-executor.ts @@ -329,8 +329,33 @@ export class StreamingExecutor { case 'WebFetch': case 'WebSearch': return ((input.url ?? input.query) as string) || undefined; - default: - return undefined; + default: { + // Generic fallback so EVERY tool shows what it's doing. For enum/router + // tools (e.g. Surf*) the `endpoint` is the real action — show it, paired + // with the most relevant param, e.g. "market/etf · BTC". Otherwise pick + // the single most meaningful argument. + const PARAM_KEYS = [ + 'query', 'q', 'search', 'prompt', 'question', 'text', + 'symbol', 'pair', 'metric', 'indicator', 'ticker', 'coin', 'asset', 'market', + 'protocol', 'handle', 'chain', 'address', 'addresses', 'hash', 'conditionId', + 'url', 'id', 'slug', 'name', 'path', 'pattern', 'to', 'number', + ]; + const firstParam = (): string => { + for (const k of PARAM_KEYS) { + const v = input[k]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + return ''; + }; + // The "action" field (endpoint / action) is the real verb — show it even + // when there's no param (e.g. PredictionMarket `leaderboard`). + const action = + (typeof input.endpoint === 'string' && input.endpoint.trim()) || + (typeof input.action === 'string' && input.action.trim()) || ''; + const combined = [action, firstParam()].filter(Boolean).join(' · '); + if (!combined) return undefined; + return combined.length > 80 ? combined.slice(0, 80) + '…' : combined; + } } } } diff --git a/src/agent/types.ts b/src/agent/types.ts index 3c3ea40f..1aaad007 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -217,4 +217,8 @@ export interface AgentConfig { maxSpendUsd?: number; /** Show user-visible harness prefetch status lines (interactive UX only). */ showPrefetchStatus?: boolean; + /** Mid-turn "research-bloat" compaction — summarizes history when a turn + * racks up many tool calls + spend, to cut input-replay cost. Default on; + * set false to disable (the desktop exposes this as a toggle). */ + costSaver?: boolean; } diff --git a/src/channel/slack.ts b/src/channel/slack.ts new file mode 100644 index 00000000..30d76996 --- /dev/null +++ b/src/channel/slack.ts @@ -0,0 +1,427 @@ +/** + * Slack ingress channel — drive Franklin from a Slack workspace. + * + * Why this exists: same motivation as the Telegram channel, but for teams. + * A persistent agent with a wallet is most useful when a whole channel can + * reach it. This module wraps Franklin's `interactiveSession` with a Slack + * Socket Mode connection: inbound @mentions (and DMs) → agent turn → streamed + * text deltas posted back into the originating thread. + * + * Multi-user: unlike Telegram's single-owner lock, Slack uses an allowlist of + * user ids (`SLACK_ALLOWED_USERS`). Anyone on the list can @mention the bot in + * a channel or DM it; everyone else is ignored. The wallet is real money, so + * an empty allowlist denies everyone by default. + * + * Session model (MVP v1): ONE shared session for the running bot, exactly like + * the Telegram channel. All authorized users share a single Franklin + * conversation. Replies always land in a thread so the channel stays tidy. + * NOTE: Hermes-style per-thread isolation (a separate concurrent session per + * Slack thread) is the planned v2 — it needs a session-manager that runs + * multiple `interactiveSession` instances at once, which this single-queue + * design intentionally does not do yet. + * + * Transport: Socket Mode (WebSocket via @slack/bolt), not Events API webhooks. + * Works behind NAT / through laptop sleep-wake without a public HTTPS endpoint. + */ + +import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm'; +import type { AgentConfig, Dialogue, StreamEvent } from '../agent/types.js'; +import { interactiveSession } from '../agent/loop.js'; +import { ModelClient } from '../agent/llm.js'; +import { extractBrainEntities } from '../brain/extract.js'; +import { extractLearnings } from '../learnings/extractor.js'; + +// Slack's hard per-message cap is ~40 KB, but readability tanks long before +// that. Keep chunks small so a long answer arrives as a few tidy messages. +const CHUNK_MAX = 3500; +// Progressive flush: emit a partial message once the buffer crosses this and +// hits a paragraph boundary, mirroring the Telegram channel's behaviour. +const PROGRESSIVE_FLUSH_MIN = 1200; + +export interface SlackOptions { + /** Bot User OAuth token (xoxb-…), from the Slack app's OAuth page. */ + botToken: string; + /** App-level token (xapp-…) with connections:write, for Socket Mode. */ + appToken: string; + /** Slack user ids allowed to drive the bot. Empty set denies everyone. */ + allowedUsers: Set; + /** Verbose: log every inbound event and turn on bolt's DEBUG logging. */ + debug?: boolean; + /** Called with each user-facing log line so the CLI can print them. */ + log?: (line: string) => void; +} + +/** + * Where a reply should be posted. `threadTs` is set for channel mentions (so + * replies stay grouped in a thread) but left undefined for top-level DMs — + * threading a DM reply hides it in a sub-thread the user isn't looking at. + */ +interface SlackTarget { + channel: string; + threadTs?: string; +} + +/** + * Split a long agent response into Slack-sized chunks. Prefers newline + * boundaries, falls back to a hard character split for pathological inputs. + * Short responses return a single chunk. Mirrors `splitForTelegram`. + */ +export function splitForSlack(text: string, max = CHUNK_MAX): string[] { + if (text.length <= max) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > max) { + const windowEnd = Math.min(max, remaining.length); + const nlIdx = remaining.lastIndexOf('\n', windowEnd - 1); + const cut = nlIdx > Math.floor(max * 0.5) ? nlIdx + 1 : windowEnd; + chunks.push(remaining.slice(0, cut)); + remaining = remaining.slice(cut); + } + if (remaining.length > 0) chunks.push(remaining); + return chunks; +} + +/** + * Progressive flush: given a growing buffer, return `{flush, keep}` where + * `flush` ends at a paragraph boundary and `keep` is the trailing partial to + * hold until more arrives. Identical strategy to the Telegram channel. + */ +export function takeProgressiveChunk( + buffer: string, + threshold = PROGRESSIVE_FLUSH_MIN, + hardCap = CHUNK_MAX, +): { flush: string; keep: string } { + const mustFlush = buffer.length > hardCap; + if (!mustFlush && buffer.length < threshold) { + return { flush: '', keep: buffer }; + } + const preferPos = buffer.lastIndexOf('\n\n', Math.min(buffer.length, hardCap) - 1); + if (preferPos > Math.floor(threshold * 0.5)) { + return { flush: buffer.slice(0, preferPos + 2), keep: buffer.slice(preferPos + 2) }; + } + const nlPos = buffer.lastIndexOf('\n', Math.min(buffer.length, hardCap) - 1); + if (nlPos > Math.floor(threshold * 0.5)) { + return { flush: buffer.slice(0, nlPos + 1), keep: buffer.slice(nlPos + 1) }; + } + if (mustFlush) { + return { flush: buffer.slice(0, hardCap), keep: buffer.slice(hardCap) }; + } + return { flush: '', keep: buffer }; +} + +/** Strip a leading `<@BOTID>` (and any extra mentions) from an app_mention. */ +function stripMentions(text: string): string { + return text.replace(/<@[A-Z0-9]+>/g, '').replace(/\s+/g, ' ').trim(); +} + +/** + * Start the bot. Resolves only on fatal error; the outer CLI handles SIGINT. + */ +export async function runSlackBot( + agentConfig: AgentConfig, + opts: SlackOptions, +): Promise { + const log = opts.log ?? (() => {}); + + // Lazy import keeps @slack/bolt out of the load path for users who never + // run the Slack bot, matching how heavy optional deps are handled elsewhere. + const { App, LogLevel } = await import('@slack/bolt'); + + const state = { + inputQueue: [] as string[], + inputWaiters: [] as Array<(v: string | null) => void>, + currentTarget: undefined as SlackTarget | undefined, + responseBuffer: '', + running: true, + restartRequested: false, + botUserId: undefined as string | undefined, + // Tools the current turn has called — posted as ONE summary on turn_done, + // mirroring the Telegram channel (a per-tool message floods the thread). + toolsUsed: [] as string[], + }; + + const app = new App({ + token: opts.botToken, + appToken: opts.appToken, + socketMode: true, + logLevel: opts.debug ? LogLevel.DEBUG : LogLevel.WARN, + }); + + // ── Slack send helpers ─────────────────────────────────────────────── + const postMessage = async (target: SlackTarget, text: string): Promise => { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await app.client.chat.postMessage({ + channel: target.channel, + ...(target.threadTs ? { thread_ts: target.threadTs } : {}), + text, + }); + return; + } catch (err) { + if (attempt === 1) { + log(`[slack] postMessage failed: ${(err as Error).message}`); + return; + } + await new Promise((r) => setTimeout(r, 2000)); + } + } + }; + + const postChunked = async (target: SlackTarget, text: string): Promise => { + const chunks = splitForSlack(text); + if (chunks.length === 1) { + await postMessage(target, chunks[0]); + return; + } + for (let i = 0; i < chunks.length; i++) { + await postMessage(target, `[${i + 1}/${chunks.length}] ${chunks[i]}`); + } + }; + + // ── Bot control commands (handled here, not by the agent) ───────────── + // Slack swallows unregistered "/foo" slash commands, but inside an + // @mention the text "@bot /new" reaches us intact, so these still work. + const handleControlCommand = async (target: SlackTarget, text: string): Promise => { + const cmd = text.trim().toLowerCase(); + switch (cmd) { + case '/help': + case 'help': + await postMessage( + target, + 'Franklin bot\n' + + '• `/new` — start a fresh conversation (clears history)\n' + + '• `/balance` — show wallet USDC balance\n' + + '• `/status` — chain, model, permission mode\n' + + 'Anything else is forwarded to the agent.', + ); + return true; + case '/new': + state.restartRequested = true; + state.inputQueue.length = 0; + // Drop tools recorded by a turn this reset interrupts, so they don't + // leak into the new conversation's first summary. + state.toolsUsed = []; + { + const waiters = state.inputWaiters.splice(0); + for (const w of waiters) w(null); + } + await postMessage(target, '🔄 Starting a new conversation…'); + return true; + case '/balance': { + try { + if (agentConfig.chain === 'solana') { + const c = await setupAgentSolanaWallet({ silent: true }); + const addr = await c.getWalletAddress(); + const bal = await c.getBalance(); + await postMessage(target, `Chain: solana\nWallet: ${addr}\nBalance: $${bal.toFixed(2)} USDC`); + } else { + const c = setupAgentWallet({ silent: true }); + const addr = c.getWalletAddress(); + const bal = await c.getBalance(); + await postMessage(target, `Chain: base\nWallet: ${addr}\nBalance: $${bal.toFixed(2)} USDC`); + } + } catch (err) { + await postMessage(target, `Couldn't fetch balance: ${(err as Error).message}`); + } + return true; + } + case '/status': + await postMessage( + target, + `chain: ${agentConfig.chain}\n` + + `model: ${agentConfig.model}\n` + + `permission: ${agentConfig.permissionMode ?? 'default'}`, + ); + return true; + default: + return false; + } + }; + + // ── Input queue (feeds interactiveSession's getUserInput) ───────────── + const enqueueInput = (target: SlackTarget, text: string): void => { + state.currentTarget = target; + if (state.inputWaiters.length > 0) { + const w = state.inputWaiters.shift()!; + w(text); + } else { + state.inputQueue.push(text); + } + }; + + const waitNextInput = (): Promise => { + if (state.restartRequested) return Promise.resolve(null); + if (state.inputQueue.length > 0) { + return Promise.resolve(state.inputQueue.shift()!); + } + if (!state.running) return Promise.resolve(null); + return new Promise((resolve) => state.inputWaiters.push(resolve)); + }; + + // ── Event sink — progressive flush with a final sweep on turn_done ──── + const flushProgressive = (): void => { + if (!state.currentTarget) return; + const { flush, keep } = takeProgressiveChunk(state.responseBuffer); + if (flush.trim()) { + const target = state.currentTarget; + state.responseBuffer = keep; + void postMessage(target, flush.trim()); + } + }; + + const handleEvent = (event: StreamEvent): void => { + switch (event.kind) { + case 'text_delta': + state.responseBuffer += event.text; + if (state.responseBuffer.length >= PROGRESSIVE_FLUSH_MIN) { + flushProgressive(); + } + break; + case 'capability_start': + // Record the tool (for the turn-end summary) and flush buffered text so + // narrative order reads right. No per-tool message — a multi-tool run + // otherwise floods the thread (same fix as the Telegram channel). + if (event.name) state.toolsUsed.push(event.name); + if (state.currentTarget && state.responseBuffer.trim()) { + const target = state.currentTarget; + const text = state.responseBuffer.trim(); + state.responseBuffer = ''; + void postMessage(target, text); + } + break; + case 'turn_done': { + const target = state.currentTarget; + const text = state.responseBuffer.trim(); + state.responseBuffer = ''; + if (target && text) void postChunked(target, text); + // One tool summary per turn, mirroring Telegram. + if (target && state.toolsUsed.length) { + const uniq = [...new Set(state.toolsUsed)]; + void postMessage(target, `🔧 Used ${state.toolsUsed.length} tool${state.toolsUsed.length === 1 ? '' : 's'}: ${uniq.join(' · ')}`); + } + state.toolsUsed = []; + if (event.reason === 'error' && event.error && target) { + void postMessage(target, `❌ Error: ${event.error}`); + } + break; + } + } + }; + + // ── Inbound routing ─────────────────────────────────────────────────── + const authorized = (userId: string | undefined): boolean => + !!userId && opts.allowedUsers.has(userId); + + const ingest = async ( + userId: string | undefined, + channel: string, + threadTs: string | undefined, + rawText: string, + ): Promise => { + const target: SlackTarget = { channel, threadTs }; + if (!authorized(userId)) { + log(`[slack] rejected unauthorized sender id=${userId ?? 'n/a'}`); + await postMessage(target, 'Not authorized.'); + return; + } + const text = stripMentions(rawText); + if (!text) return; + log(`[slack] ← ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`); + + if (text.startsWith('/') || text.toLowerCase() === 'help') { + state.currentTarget = target; + const handled = await handleControlCommand(target, text); + if (handled) return; + // Unknown command falls through to the agent (it has its own slash + // handling for /retry, /model, /cost, …). + } + enqueueInput(target, text); + }; + + // app_mention: someone @mentioned the bot in a channel. Reply in-thread so + // the conversation stays grouped; a top-level mention starts a new thread. + app.event('app_mention', async ({ event }) => { + const e = event as { user?: string; channel: string; ts: string; thread_ts?: string; text: string }; + await ingest(e.user, e.channel, e.thread_ts ?? e.ts, e.text); + }); + + // Direct messages to the bot. Ignore the bot's own messages, edits, and + // any message that carries a subtype (joins, file shares, etc.). + app.message(async ({ message }) => { + const m = message as { + channel_type?: string; + subtype?: string; + user?: string; + bot_id?: string; + channel: string; + ts: string; + thread_ts?: string; + text?: string; + }; + if (opts.debug) { + log( + `[slack] message event: channel_type=${m.channel_type} subtype=${m.subtype ?? '-'} ` + + `bot_id=${m.bot_id ?? '-'} user=${m.user ?? '-'} text=${(m.text ?? '').slice(0, 40)}`, + ); + } + if (m.channel_type !== 'im') return; // channel posts arrive via app_mention + if (m.subtype || m.bot_id || !m.text) return; + if (m.user && m.user === state.botUserId) return; + // DMs reply at top level; only stay threaded if the user is already in one. + await ingest(m.user, m.channel, m.thread_ts, m.text); + }); + + // ── Connect ──────────────────────────────────────────────────────────── + try { + const auth = (await app.client.auth.test()) as { user_id?: string; team?: string }; + state.botUserId = auth.user_id; + await app.start(); + log( + `[slack] connected as bot ${auth.user_id ?? '(unknown)'} ` + + `team=${auth.team ?? '?'} — ${opts.allowedUsers.size} allowed user(s)`, + ); + } catch (err) { + throw new Error(`Slack connect failed: ${(err as Error).message}`); + } + + // Shared LLM client for post-session extraction (built once). + const extractor = new ModelClient({ + apiUrl: agentConfig.apiUrl, + chain: agentConfig.chain, + }); + + const harvestSession = async (history: Dialogue[]): Promise => { + if (history.length < 4) return; + const sid = `slack-${new Date().toISOString()}`; + try { + await Promise.race([ + Promise.all([ + extractLearnings(history, sid, extractor), + extractBrainEntities(history, sid, extractor), + ]), + new Promise((r) => setTimeout(r, 15_000)), + ]); + } catch (err) { + log(`[slack] post-session extraction failed: ${(err as Error).message}`); + } + }; + + // ── Outer session loop (mirrors the Telegram channel) ───────────────── + try { + let firstSession = true; + while (state.running) { + state.restartRequested = false; + if (!firstSession) agentConfig.resumeSessionId = undefined; + firstSession = false; + const history = await interactiveSession(agentConfig, waitNextInput, handleEvent); + void harvestSession(history); + if (!state.restartRequested) break; + log('[slack] session reset by /new'); + } + } finally { + state.running = false; + const waiters = state.inputWaiters.splice(0); + for (const w of waiters) w(null); + await app.stop().catch(() => {}); + } +} diff --git a/src/channel/telegram.ts b/src/channel/telegram.ts index 199918b3..250d1ed6 100644 --- a/src/channel/telegram.ts +++ b/src/channel/telegram.ts @@ -16,6 +16,9 @@ * HTTPS endpoint. `node fetch` is the only HTTP dep. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm'; import type { AgentConfig, Dialogue, StreamEvent } from '../agent/types.js'; import { interactiveSession } from '../agent/loop.js'; @@ -23,6 +26,18 @@ import { ModelClient } from '../agent/llm.js'; import { extractBrainEntities } from '../brain/extract.js'; import { extractLearnings } from '../learnings/extractor.js'; +// Per-bot prefs (persisted so a restart keeps the user's choice). +const PREFS_FILE = path.join(os.homedir(), '.blockrun', 'telegram-prefs.json'); +function loadPrefs(): { showTools?: boolean } { + try { return JSON.parse(fs.readFileSync(PREFS_FILE, 'utf-8')); } catch { return {}; } +} +function savePrefs(prefs: { showTools?: boolean }): void { + try { + fs.mkdirSync(path.dirname(PREFS_FILE), { recursive: true }); + fs.writeFileSync(PREFS_FILE, JSON.stringify(prefs, null, 2), { mode: 0o600 }); + } catch { /* best-effort */ } +} + const TG_API = 'https://api.telegram.org'; const POLL_TIMEOUT_SECONDS = 25; // Telegram caps messages at 4096 chars; keep a margin so our chunk headers @@ -38,6 +53,10 @@ export interface TelegramOptions { token: string; /** Numeric Telegram user id that's allowed to drive the bot. Required. */ ownerId: number; + /** Extra numeric user ids allowed to drive the bot (e.g. other people in a + * group). The owner is always allowed; this widens access without dropping + * the lock. Empty/undefined → owner-only (original behaviour). */ + allowedUsers?: Set; /** Called with each user-facing log line so the CLI can print them. */ log?: (line: string) => void; } @@ -46,9 +65,10 @@ interface TgUpdate { update_id: number; message?: { message_id: number; - chat: { id: number }; + chat: { id: number; type?: string }; from?: { id: number; username?: string; first_name?: string }; text?: string; + reply_to_message?: { from?: { id?: number; is_bot?: boolean } }; }; } @@ -123,6 +143,10 @@ export async function runTelegramBot( running: true, restartRequested: false, stoppedBy: undefined as Error | undefined, + // Tool names used in the current turn → one summary at turn end (not one + // message per call). `showTools` gates whether that summary is sent. + toolsUsed: [] as string[], + showTools: loadPrefs().showTools ?? true, }; // ── Telegram HTTP helpers ──────────────────────────────────────────── @@ -168,6 +192,16 @@ export async function runTelegramBot( // ── Slash commands (handled by the bot, not the agent) ────────────── const handleSlashCommand = async (chatId: number, text: string): Promise => { const cmd = text.trim().toLowerCase(); + + // `/tools` toggles the per-turn tool summary (takes on/off, or bare = flip). + if (cmd === '/tools' || cmd.startsWith('/tools ')) { + const arg = cmd.slice('/tools'.length).trim(); + state.showTools = arg === 'on' ? true : arg === 'off' ? false : !state.showTools; + savePrefs({ showTools: state.showTools }); + await sendMessage(chatId, `🔧 Tool summary: ${state.showTools ? 'on ✅' : 'off'}`); + return true; + } + switch (cmd) { case '/start': case '/help': @@ -175,6 +209,7 @@ export async function runTelegramBot( chatId, 'Franklin bot\n\n' + '/new — start a fresh conversation (clears history)\n' + + '/tools [on|off] — toggle the per-turn tool-usage summary\n' + '/balance — show wallet USDC balance\n' + '/status — show chain, model, and session stats\n' + '/help — this message\n\n' + @@ -185,6 +220,9 @@ export async function runTelegramBot( state.restartRequested = true; // Drain any pending input and wake the session so it unwinds. state.inputQueue.length = 0; + // Drop tools recorded by a turn this reset interrupts, so they don't + // leak into the new conversation's first summary. + state.toolsUsed = []; { const waiters = state.inputWaiters.splice(0); for (const w of waiters) w(null); @@ -268,16 +306,15 @@ export async function runTelegramBot( } break; case 'capability_start': - // Best-effort signal that the agent is working. Flush any buffered - // text first so the user sees the narrative order correctly. - if (state.currentChatId !== undefined) { - if (state.responseBuffer.trim()) { - const chatId = state.currentChatId; - const text = state.responseBuffer.trim(); - state.responseBuffer = ''; - void sendMessage(chatId, text); - } - void sendMessage(state.currentChatId, `⏳ ${event.name}…`); + // Record the tool (for the turn-end summary) and flush buffered text so + // narrative order reads right. No per-tool message — a multi-tool run + // otherwise floods the chat. + if (event.name) state.toolsUsed.push(event.name); + if (state.currentChatId !== undefined && state.responseBuffer.trim()) { + const chatId = state.currentChatId; + const text = state.responseBuffer.trim(); + state.responseBuffer = ''; + void sendMessage(chatId, text); } break; case 'turn_done': { @@ -285,6 +322,12 @@ export async function runTelegramBot( const text = state.responseBuffer.trim(); state.responseBuffer = ''; if (chatId !== undefined && text) void sendChunked(chatId, text); + // One tool summary per turn (toggle with /tools). + if (chatId !== undefined && state.showTools && state.toolsUsed.length) { + const uniq = [...new Set(state.toolsUsed)]; + void sendMessage(chatId, `🔧 Used ${state.toolsUsed.length} tool${state.toolsUsed.length === 1 ? '' : 's'}: ${uniq.join(' · ')}`); + } + state.toolsUsed = []; if (event.reason === 'error' && event.error && chatId !== undefined) { void sendMessage(chatId, `❌ Error: ${event.error}`); } @@ -294,10 +337,21 @@ export async function runTelegramBot( }; // ── Long-poll loop (runs concurrently with interactiveSession) ────── + // Captured from getMe so the group @mention gate knows the bot's handle/id. + let botUsername: string | undefined; + let botId: number | undefined; + const pollLoop = async (): Promise => { try { - const me = await api<{ username?: string }>('getMe', {}); - log(`[telegram] connected as @${me.username ?? '(unknown)'} — owner=${opts.ownerId}`); + const me = await api<{ id?: number; username?: string }>('getMe', {}); + botUsername = me.username; + botId = me.id; + log( + `[telegram] connected as @${me.username ?? '(unknown)'} — owner=${opts.ownerId}` + + (opts.allowedUsers && opts.allowedUsers.size + ? ` + ${opts.allowedUsers.size} allowed user(s)` + : ''), + ); } catch (err) { state.stoppedBy = err as Error; state.running = false; @@ -323,7 +377,27 @@ export async function runTelegramBot( state.offset = u.update_id + 1; const msg = u.message; if (!msg?.text || !msg.from) continue; - if (msg.from.id !== opts.ownerId) { + + // In groups, only act when the bot is addressed: @mentioned (incl. the + // `/cmd@bot` form) or replied to. Everything else — plain chatter AND + // bare slash commands — is ignored SILENTLY. Private chats need no mention. + const isGroup = !!msg.chat.type && msg.chat.type !== 'private'; + let text = msg.text; + if (isGroup) { + const tag = botUsername ? `@${botUsername}` : ''; + const mentioned = !!tag && text.toLowerCase().includes(tag.toLowerCase()); + const repliedToBot = !!botId && msg.reply_to_message?.from?.id === botId; + if (!mentioned && !repliedToBot) continue; + // Strip the @mention so the agent gets a clean prompt. + if (mentioned && tag) { + text = text.replace(new RegExp(tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig'), '').trim(); + } + } + if (!text) continue; // mention with no actual content + + const isAuthorized = + msg.from.id === opts.ownerId || !!opts.allowedUsers?.has(msg.from.id); + if (!isAuthorized) { void sendMessage(msg.chat.id, 'Not authorized.'); log( `[telegram] rejected unauthorized sender id=${msg.from.id} ` + @@ -331,18 +405,18 @@ export async function runTelegramBot( ); continue; } - log(`[telegram] ← ${msg.text.slice(0, 80)}${msg.text.length > 80 ? '…' : ''}`); + log(`[telegram] ← ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`); // Intercept bot slash commands before handing off to the agent. - if (msg.text.trim().startsWith('/')) { + if (text.trim().startsWith('/')) { state.currentChatId = msg.chat.id; - const handled = await handleSlashCommand(msg.chat.id, msg.text); + const handled = await handleSlashCommand(msg.chat.id, text); if (handled) continue; // Unknown slash command: fall through to agent (which has its own // slash handling for /retry, /model, /cost, …). } - enqueueInput(msg.chat.id, msg.text); + enqueueInput(msg.chat.id, text); } } }; diff --git a/src/commands/config.ts b/src/commands/config.ts index b5cecae7..4c51ecda 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -15,6 +15,7 @@ const VALID_KEYS = [ 'permission-mode', 'max-turns', 'auto-compact', + 'cost-saver', 'session-save', 'debug', 'zerox-api-key', @@ -32,6 +33,8 @@ export interface AppConfig { 'permission-mode'?: string; 'max-turns'?: string; 'auto-compact'?: string; + /** Research-bloat compaction toggle for the desktop ("false" disables). */ + 'cost-saver'?: string; 'session-save'?: string; 'debug'?: string; /** 0x V2 Swap API key for Base swaps. Free at https://dashboard.0x.org. Each user supplies their own; the on-chain affiliate fee routes to BlockRun regardless. */ @@ -70,6 +73,13 @@ function isValidKey(key: string): key is ConfigKey { return VALID_KEYS.includes(key as ConfigKey); } +/** Persist a single config key (used by the desktop server for live toggles). */ +export function setConfigValue(key: ConfigKey, value: string): void { + const config = loadConfig(); + config[key] = value; + saveConfig(config); +} + export function configCommand( action: string, keyOrUndefined?: string, diff --git a/src/commands/serve.ts b/src/commands/serve.ts new file mode 100644 index 00000000..95c8592d --- /dev/null +++ b/src/commands/serve.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; +import { startServer } from '../serve/server.js'; + +interface ServeOptions { + port?: string; + workDir?: string; + debug?: boolean; +} + +export async function serveCommand(options: ServeOptions): Promise { + const port = Number(options.port) || 3737; + const workDir = options.workDir ? path.resolve(options.workDir) : process.cwd(); + await startServer({ port, workDir, debug: !!options.debug }); +} diff --git a/src/commands/slack.ts b/src/commands/slack.ts new file mode 100644 index 00000000..4c374cfe --- /dev/null +++ b/src/commands/slack.ts @@ -0,0 +1,144 @@ +/** + * `franklin slack` — start the Slack ingress bot. + * + * Designed to run on a server / always-on laptop. Reads the bot token, app + * token, and the user allowlist from env (or ~/.blockrun/config). Uses + * trust-mode permissions because the operator is remote — there's no terminal + * prompt they can answer per tool call. The `SLACK_ALLOWED_USERS` allowlist in + * `runSlackBot` is the real security boundary, mirroring Telegram's owner lock. + */ + +import chalk from 'chalk'; +import { loadChain, API_URLS } from '../config.js'; +import { assembleInstructions } from '../agent/context.js'; +import { allCapabilities } from '../tools/index.js'; +import { loadMcpConfig } from '../mcp/config.js'; +import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js'; +import { loadConfig } from './config.js'; +import { runSlackBot } from '../channel/slack.js'; +import { findLatestSessionByChannel } from '../session/storage.js'; +import type { AgentConfig } from '../agent/types.js'; + +interface SlackCommandOptions { + model?: string; + debug?: boolean; +} + +export async function slackCommand(opts: SlackCommandOptions): Promise { + const botToken = process.env.SLACK_BOT_TOKEN; + const appToken = process.env.SLACK_APP_TOKEN; + const allowedRaw = process.env.SLACK_ALLOWED_USERS; + + if (!botToken || !appToken || !allowedRaw) { + console.error(chalk.red('Missing Slack config.')); + console.error(chalk.dim( + '\nSet three env vars before running `franklin slack`:\n' + + ' SLACK_BOT_TOKEN=\n' + + ' SLACK_APP_TOKEN=\n' + + ' SLACK_ALLOWED_USERS=\n\n' + + 'Socket Mode must be enabled on the app, and the bot must be invited to\n' + + 'the channel (/invite @your-bot). Find a user id via their profile →\n' + + '⋮ → Copy member ID.', + )); + process.exit(1); + } + + const allowedUsers = new Set( + allowedRaw.split(',').map((s) => s.trim()).filter(Boolean), + ); + if (allowedUsers.size === 0) { + console.error(chalk.red('SLACK_ALLOWED_USERS is empty — that would deny everyone.')); + process.exit(1); + } + + const chain = loadChain(); + const apiUrl = API_URLS[chain]; + const config = loadConfig(); + + const model = + opts.model || + config['default-model'] || + 'nvidia/qwen3-coder-480b'; + + const workingDir = process.cwd(); + const systemInstructions = assembleInstructions(workingDir, model); + + // Connect MCP servers (Notion, etc.) so the bot exposes their tools — mirrors + // what `franklin start` does. Without this the bot only has built-in tools. + const mcpConfig = loadMcpConfig(workingDir); + let mcpTools: typeof allCapabilities = []; + const mcpServerCount = Object.keys(mcpConfig.mcpServers).filter( + (k) => !mcpConfig.mcpServers[k].disabled, + ).length; + if (mcpServerCount > 0) { + try { + mcpTools = await connectMcpServers(mcpConfig, opts.debug); + if (mcpTools.length > 0) { + console.log(chalk.dim(` MCP: ${mcpTools.length} tools from ${mcpServerCount} server(s)`)); + } + } catch (err) { + console.error(chalk.yellow(` MCP error: ${(err as Error).message}`)); + } + } + + // Resume the most recent session tagged for THIS workspace bot so a process + // restart doesn't drop the conversation. MVP v1 keeps one shared session per + // bot (see channel/slack.ts), so the channel tag is workspace-scoped. + const channelTag = 'slack:shared'; + const prior = findLatestSessionByChannel(channelTag); + if (prior) { + console.log(chalk.dim( + ` resuming session ${prior.id} (${prior.messageCount} msgs, ` + + `last update ${new Date(prior.updatedAt).toLocaleString()})`, + )); + } + + const agentConfig: AgentConfig = { + model, + apiUrl, + chain, + systemInstructions, + capabilities: [...allCapabilities, ...mcpTools], + workingDir, + // No interactive terminal for permission prompts — remote operator can't + // answer y/n per tool. The Slack allowlist is the security boundary. + permissionMode: 'trust', + debug: opts.debug, + sessionChannel: channelTag, + resumeSessionId: prior?.id, + }; + + console.log(chalk.bold.cyan('Franklin Slack bot')); + console.log(chalk.dim(` chain: ${chain}`)); + console.log(chalk.dim(` model: ${model}`)); + console.log(chalk.dim(` allowed users: ${allowedUsers.size}`)); + console.log(chalk.yellow( + ' permission mode: trust — every tool the model picks will execute ' + + 'without confirmation. The allowlist is your only gate.\n', + )); + + let exitAttempts = 0; + process.on('SIGINT', () => { + exitAttempts++; + if (exitAttempts === 1) { + console.log(chalk.dim('\nStopping… (press Ctrl-C again to force)')); + } else { + process.exit(130); + } + }); + + try { + await runSlackBot(agentConfig, { + botToken, + appToken, + allowedUsers, + debug: opts.debug, + log: (line) => console.log(chalk.dim(line)), + }); + } catch (err) { + console.error(chalk.red(`Slack bot failed: ${(err as Error).message}`)); + process.exit(1); + } finally { + disconnectMcpServers().catch(() => {}); + } +} diff --git a/src/commands/telegram.ts b/src/commands/telegram.ts index 33aa0c48..9f39df11 100644 --- a/src/commands/telegram.ts +++ b/src/commands/telegram.ts @@ -43,6 +43,14 @@ export async function telegramCommand(opts: TelegramCommandOptions): Promise([ownerId]); + for (const raw of (process.env.TELEGRAM_ALLOWED_USERS ?? '').split(',')) { + const id = parseInt(raw.trim(), 10); + if (Number.isFinite(id) && id > 0) allowedUsers.add(id); + } + const chain = loadChain(); const apiUrl = API_URLS[chain]; const config = loadConfig(); @@ -109,6 +117,7 @@ export async function telegramCommand(opts: TelegramCommandOptions): Promise console.log(chalk.dim(line)), }); } catch (err) { diff --git a/src/index.ts b/src/index.ts index 3329a0fa..9e7dc7d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { configCommand } from './commands/config.js'; import { statsCommand } from './commands/stats.js'; import { logsCommand } from './commands/logs.js'; import { daemonCommand } from './commands/daemon.js'; +import { slackCommand } from './commands/slack.js'; import { initCommand } from './commands/init.js'; import { uninitCommand } from './commands/uninit.js'; import { proxyCommand } from './commands/proxy.js'; @@ -106,6 +107,13 @@ program .option('-p, --port ', 'Proxy port', '8402') .action((action, options) => daemonCommand(action, options)); +program + .command('slack') + .description('Run the Slack ingress bot (Socket Mode)') + .option('--model ', 'Model to use') + .option('--debug', 'Verbose Slack/Bolt logging') + .action((options) => slackCommand(options)); + program .command('panel') .description('Open the Franklin dashboard (localhost:3100)') @@ -115,6 +123,17 @@ program await panelCommand(options); }); +program + .command('serve') + .description('Run the local agent server for the desktop app / browser UI (WebSocket on localhost:3737)') + .option('-p, --port ', 'Agent server port', '3737') + .option('--work-dir ', 'Working directory for tools (default: cwd)') + .option('--debug', 'Verbose logging') + .action(async (options: { port?: string; workDir?: string; debug?: boolean }) => { + const { serveCommand } = await import('./commands/serve.js'); + await serveCommand(options); + }); + program .command('models') .description('List available models and pricing') @@ -376,5 +395,10 @@ if (firstArg === 'solana' || firstArg === 'base') { await startCommand(startOpts as Parameters[0]); process.exit(process.exitCode ?? 0); } else { - program.parse(); + // Force node-style argv slicing. When the CLI is embedded and run via + // Electron-as-node (ELECTRON_RUN_AS_NODE=1, e.g. the desktop app spawning + // `franklin serve`), commander otherwise detects `process.versions.electron` + // + no defaultApp and slices argv as a packaged-electron app — treating the + // script path as the command. `from: 'node'` keeps [exec, script, ...args]. + program.parse(process.argv, { from: 'node' }); } diff --git a/src/serve/cloud-sync.ts b/src/serve/cloud-sync.ts new file mode 100644 index 00000000..a914f71f --- /dev/null +++ b/src/serve/cloud-sync.ts @@ -0,0 +1,125 @@ +/** + * Cloud sync for desktop chat history — the local agent acts as the bridge. + * + * Identity is the local Base wallet (~/.blockrun). We run the SAME SIWE flow a + * browser does against franklin.run (/api/try/auth/nonce → sign → verify), hold + * the session, and proxy conversation load/save/delete to the existing + * /api/try/conversations API (GCS-backed, per-wallet). So the desktop and the + * web share one history keyed by wallet — and the web server needs no changes. + * + * Everything is best-effort: callers fall back to the local file on any failure + * (offline, not-deployed, auth hiccup), so cloud sync never breaks local use. + */ + +import { getOrCreateWallet } from '@blockrun/llm'; +import { privateKeyToAccount } from 'viem/accounts'; + +const CLOUD_BASE = process.env.FRANKLIN_CLOUD_URL || 'https://franklin.run'; +const NONCE_COOKIE = 'franklin_try_nonce'; +const SESSION_COOKIE = 'franklin_try_session'; +const TIMEOUT = 8000; + +export interface CloudConversation { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messages: unknown[]; +} + +export function isCloudSyncEnabled(): boolean { + return process.env.FRANKLIN_CLOUD_SYNC !== 'off'; +} + +let sessionCookie: string | null = null; +// Track what we've pushed so save only sends changed/removed conversations. +let lastSynced = new Map(); + +function getSetCookie(res: Response, name: string): string | null { + const list = (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? []; + for (const c of list) if (c.startsWith(name + '=')) return c.split(';')[0]; + return null; +} + +async function login(): Promise { + const nonceRes = await fetch(`${CLOUD_BASE}/api/try/auth/nonce`, { signal: AbortSignal.timeout(TIMEOUT) }); + if (!nonceRes.ok) throw new Error(`nonce ${nonceRes.status}`); + const nonceCookie = getSetCookie(nonceRes, NONCE_COOKIE); + const { nonce } = (await nonceRes.json()) as { nonce?: string }; + if (!nonce || !nonceCookie) throw new Error('no nonce'); + + const { privateKey, address } = getOrCreateWallet(); + const account = privateKeyToAccount(privateKey as `0x${string}`); + const message = `Sign in to Franklin Desktop\n\nNonce: ${nonce}`; + const signature = await account.signMessage({ message }); + + const verifyRes = await fetch(`${CLOUD_BASE}/api/try/auth/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: nonceCookie }, + body: JSON.stringify({ address, message, signature }), + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!verifyRes.ok) throw new Error(`verify ${verifyRes.status}`); + const session = getSetCookie(verifyRes, SESSION_COOKIE); + if (!session) throw new Error('no session cookie'); + sessionCookie = session; +} + +async function authed(path: string, init: RequestInit = {}): Promise { + if (!sessionCookie) await login(); + const doFetch = () => fetch(`${CLOUD_BASE}${path}`, { + ...init, + headers: { ...(init.headers || {}), Cookie: sessionCookie! }, + signal: AbortSignal.timeout(TIMEOUT), + }); + let res = await doFetch(); + if (res.status === 401) { sessionCookie = null; await login(); res = await doFetch(); } + return res; +} + +export async function cloudList(): Promise { + const res = await authed('/api/try/conversations'); + if (!res.ok) throw new Error(`list ${res.status}`); + const j = (await res.json()) as { conversations?: CloudConversation[] }; + const convos = Array.isArray(j.conversations) ? j.conversations : []; + lastSynced = new Map(convos.map((c) => [c.id, c.updatedAt])); + return convos; +} + +async function cloudPut(c: CloudConversation): Promise { + const res = await authed(`/api/try/conversations/${encodeURIComponent(c.id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(c), + }); + if (!res.ok) throw new Error(`put ${res.status}`); +} + +async function cloudDelete(id: string): Promise { + try { await authed(`/api/try/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' }); } catch { /* ignore */ } +} + +// Sync passes are serialized: cloudSync is called fire-and-forget from both +// history.load (migration) and history.save, and a pass reads + rewrites the +// module-level `lastSynced` map across awaited network calls. Two interleaved +// passes corrupt that shared state — worst case the delete sweep walks a stale +// snapshot and removes a conversation a concurrent pass just uploaded. +let syncQueue: Promise = Promise.resolve(); + +/** Reconcile cloud to match the given local list: upsert changed, delete removed. */ +export function cloudSync(conversations: CloudConversation[]): Promise { + const run = syncQueue.then(() => doCloudSync(conversations)); + syncQueue = run.catch(() => {}); // keep the chain alive after a failed pass + return run; +} + +async function doCloudSync(conversations: CloudConversation[]): Promise { + const current = new Map(conversations.map((c) => [c.id, c.updatedAt])); + for (const c of conversations) { + if (lastSynced.get(c.id) !== c.updatedAt) await cloudPut(c); + } + for (const id of [...lastSynced.keys()]) { + if (!current.has(id)) await cloudDelete(id); + } + lastSynced = current; +} diff --git a/src/serve/server.ts b/src/serve/server.ts new file mode 100644 index 00000000..8d2541fa --- /dev/null +++ b/src/serve/server.ts @@ -0,0 +1,714 @@ +/** + * Franklin agent server (local WebSocket — drives the desktop app & browser UI). + * + * Serves the local React WebUI (franklin-webui / the desktop app) over a single + * WebSocket using the envelope wire protocol the UI already speaks: + * + * client → { id, kind, payload } (agent.send / session.* / wallet.info / …) + * server → { id, kind, payload } (agent.text / agent.step / agent.done / …) + * + * Unlike `franklin panel` (a read-only dashboard), this actually runs agent + * turns: it drives the real `interactiveSession` loop from src/agent/loop.ts — + * same tools, wallet, routing and signing as the CLI. The browser/desktop is + * just a different head on the same agent. + * + * Single-window assumption: one long-lived agent session per server process, + * fed by a getUserInput queue. Good enough for the desktop app; multi-session + * fan-out can come later. + */ + +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import WebSocket from 'ws'; +import { loadChain, API_URLS, BLOCKRUN_DIR } from '../config.js'; +import { loadConfig, setConfigValue } from '../commands/config.js'; +import { assembleInstructions } from '../agent/context.js'; +import { interactiveSession } from '../agent/loop.js'; +import { allCapabilities, createSubAgentCapability } from '../tools/index.js'; +import { getModelsByCategory } from '../gateway-models.js'; +import { listSessions, loadSessionHistory } from '../session/storage.js'; +import { loadSdkSettlements } from '../stats/cost-log.js'; +import { readSwaps } from '../stats/swap-log.js'; +import { isCloudSyncEnabled, cloudList, cloudSync, type CloudConversation } from './cloud-sync.js'; +import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm'; +import { retryFetchBalance } from '../commands/balance-retry.js'; +import type { AgentConfig, StreamEvent, Dialogue, ContentPart, UserContentPart } from '../agent/types.js'; + +const FREE_DEFAULT_MODEL = 'nvidia/deepseek-v4-flash'; + +// Curated Base (chainId 8453) tokens for the wallet "holdings" view. Plain RPC +// can't enumerate every token an address holds (no on-chain "list all"), so we +// balanceOf a known set and show the non-zero ones. `stable` → USD ≈ amount. +const BASE_PUBLIC_RPCS = ['https://base.publicnode.com', 'https://mainnet.base.org', 'https://base.meowrpc.com']; +const BASE_TOKENS: Array<{ symbol: string; address: string; decimals: number; stable?: boolean; cg?: string }> = [ + { symbol: 'USDC', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, stable: true }, + { symbol: 'USDbC', address: '0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA', decimals: 6, stable: true }, + { symbol: 'USDT', address: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', decimals: 6, stable: true }, + { symbol: 'DAI', address: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', decimals: 18, stable: true }, + { symbol: 'WETH', address: '0x4200000000000000000000000000000000000006', decimals: 18, cg: 'ethereum' }, + { symbol: 'cbBTC', address: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', decimals: 8, cg: 'bitcoin' }, + { symbol: 'cbETH', address: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22', decimals: 18, cg: 'ethereum' }, + { symbol: 'AERO', address: '0x940181a94A35A4569E4529A3CDfB74e38FD98631', decimals: 18, cg: 'aerodrome-finance' }, + { symbol: 'DEGEN', address: '0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed', decimals: 18, cg: 'degen-base' }, +]; + +// USD prices via CoinGecko (free, no key). Best-effort: on failure, tokens just +// show without a USD value rather than blocking the holdings list. +async function fetchCgPrices(ids: string[]): Promise> { + if (ids.length === 0) return {}; + try { + const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent([...new Set(ids)].join(','))}&vs_currencies=usd`; + const r = await fetch(url, { signal: AbortSignal.timeout(6000) }); + const j = await r.json() as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(j)) if (typeof v?.usd === 'number') out[k] = v.usd; + return out; + } catch { return {}; } +} + +async function baseRpc(method: string, params: unknown[]): Promise { + for (const url of BASE_PUBLIC_RPCS) { + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(6000), + }); + const j = await r.json() as { result?: string }; + if (j && typeof j.result === 'string') return j.result; + } catch { /* try next rpc */ } + } + return null; +} + +function hexToAmount(hex: string | null, decimals: number): number { + if (!hex || hex === '0x') return 0; + try { + const v = BigInt(hex); + if (v === 0n) return 0; + // Scale down with enough precision for display. + return Number(v) / 10 ** decimals; + } catch { return 0; } +} + +/** Best-effort list of an address's holdings (native ETH + curated ERC-20s). */ +async function listBaseHoldings(address: string): Promise> { + const out: Array<{ symbol: string; amount: number; usd?: number }> = []; + const addr = address.toLowerCase().replace(/^0x/, '').padStart(64, '0'); + const DUST = 1e-6; // hide negligible dust (e.g. post-swap leftovers) that'd render as "0" + // Collect raw balances (amount + which coingecko id to price it with). + const raw: Array<{ symbol: string; amount: number; stable?: boolean; cg?: string }> = []; + const ethHex = await baseRpc('eth_getBalance', [address, 'latest']); + const eth = hexToAmount(ethHex, 18); + if (eth >= DUST) raw.push({ symbol: 'ETH', amount: eth, cg: 'ethereum' }); + await Promise.all(BASE_TOKENS.map(async (t) => { + const data = '0x70a08231' + addr; // balanceOf(address) + const hex = await baseRpc('eth_call', [{ to: t.address, data }, 'latest']); + const amt = hexToAmount(hex, t.decimals); + if (amt >= DUST) raw.push({ symbol: t.symbol, amount: amt, stable: t.stable, cg: t.cg }); + })); + // Price the non-stable holdings (stable ≈ $1) and compute USD value per token. + const prices = await fetchCgPrices(raw.filter((r) => !r.stable && r.cg).map((r) => r.cg!)); + for (const r of raw) { + const usd = r.stable ? r.amount : (r.cg && prices[r.cg] != null ? r.amount * prices[r.cg] : undefined); + out.push({ symbol: r.symbol, amount: r.amount, ...(usd != null ? { usd } : {}) }); + } + return out.sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0) || b.amount - a.amount); +} + +// Friendly, provider-tagged labels for the activity log (mirrors franklin-run), +// so a finished step reads "Checking prediction markets · Predexon" instead of +// the raw tool name. Unknown tools fall back to their own name. +const TOOL_LABELS: Record = { + web_search: 'Searching the web · Exa', + search_prediction_markets: 'Checking prediction markets · Predexon', + get_market_price: 'Fetching live price', + generate_music: 'Composing music', + make_phone_call: 'Placing phone call', +}; +function labelFor(name: string): string { + return TOOL_LABELS[name] ?? name; +} + +// Model list grouping — by provider (company), like OpenRouter/Together. The +// provider is the id's vendor prefix (e.g. "anthropic/claude-…"); PROVIDER_ORDER +// puts the most-wanted vendors first, the rest fall in alphabetically. +const PROVIDER_LABEL: Record = { + anthropic: 'Anthropic', openai: 'OpenAI', azure: 'OpenAI', google: 'Google', 'google-vertex': 'Google', + xai: 'xAI', deepseek: 'DeepSeek', meta: 'Meta', 'meta-llama': 'Meta', nvidia: 'NVIDIA', + moonshot: 'Moonshot', moonshotai: 'Moonshot', qwen: 'Qwen', alibaba: 'Qwen', mistral: 'Mistral', + mistralai: 'Mistral', minimax: 'MiniMax', zhipu: 'Zhipu', bytedance: 'ByteDance', cohere: 'Cohere', + perplexity: 'Perplexity', amazon: 'Amazon', microsoft: 'Microsoft', '01-ai': 'Yi', ai21: 'AI21', +}; +const PROVIDER_ORDER = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'DeepSeek', 'Qwen', 'Moonshot', 'Meta', 'Mistral', 'MiniMax', 'NVIDIA']; +function providerLabel(id: string, ownedBy?: string): string { + const p = (id.split('/')[0] || ownedBy || '').toLowerCase(); + return PROVIDER_LABEL[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Other'); +} + +// ─── Browser-attack surface gate ──────────────────────────────────────────── +// Loopback binding alone is NOT an auth boundary: any web page the user has +// open can reach 127.0.0.1 (the browser attaches an Origin header but happily +// completes the request — WS handshakes aren't blocked by CORS, and a wallet- +// bearing agent in trust mode must not be drivable by a drive-by page). +// +// Policy: requests WITHOUT an Origin header are local processes (Electron main, +// curl, native clients — browsers can't strip Origin) → allowed. Browser +// origins are allowed only for Electron renderers (file:// / app://), local +// UIs (localhost / 127.0.0.1), the hosted web UI, and anything listed in +// FRANKLIN_SERVE_ALLOWED_ORIGINS (comma-separated). The literal "null" origin +// is REJECTED by default — sandboxed iframes on hostile pages also serialize +// to "null" — set FRANKLIN_SERVE_ALLOW_NULL_ORIGIN=1 if a renderer needs it. +// Defense-in-depth: when FRANKLIN_SERVE_TOKEN is set, every WS upgrade and +// /file request must also carry it (?token=…). +const DEFAULT_ALLOWED_ORIGINS = ['https://franklin.run']; +function isOriginAllowed(origin: string | undefined): boolean { + if (!origin) return true; // non-browser local client + if (origin === 'null') return process.env.FRANKLIN_SERVE_ALLOW_NULL_ORIGIN === '1'; + if (origin.startsWith('file://') || origin.startsWith('app://')) return true; // Electron renderer + let host = ''; + try { host = new URL(origin).hostname; } catch { return false; } + if (host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1') return true; + const extra = (process.env.FRANKLIN_SERVE_ALLOWED_ORIGINS || '') + .split(',').map((s) => s.trim()).filter(Boolean); + return [...DEFAULT_ALLOWED_ORIGINS, ...extra].includes(origin); +} +function tokenOk(url: URL): boolean { + const required = process.env.FRANKLIN_SERVE_TOKEN; + if (!required) return true; + return url.searchParams.get('token') === required; +} + +interface ServerOptions { + port: number; + workDir: string; + debug?: boolean; +} + +// ─── Wire envelope ────────────────────────────────────────────────────────── + +interface ClientMsg { + id: string; + kind: string; + payload?: unknown; +} + +function send(ws: WebSocket, id: string, kind: string, payload?: unknown): void { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ id, kind, payload })); +} + +// Flatten a stored Dialogue into the {role, content, kind:'text'} shape the UI +// renders. Tool calls / images are dropped here (the live stream carries those +// for the active turn); history replay just needs the text. +function dialogueText(content: Dialogue['content']): string { + if (typeof content === 'string') return content; + const parts = content as Array; + return parts + .map((p) => (p && typeof p === 'object' && 'type' in p && p.type === 'text' ? (p as { text: string }).text : '')) + .filter(Boolean) + .join(''); +} + +export async function startServer(opts: ServerOptions): Promise { + const { port, workDir, debug } = opts; + const chain = loadChain(); + const apiUrl = API_URLS[chain]; + const userConfig = loadConfig(); + + // ── Single long-lived agent session ── + // interactiveSession owns the loop; we feed it user turns via a queue and + // fan its StreamEvents out to the connected socket. + let sessionStarted = false; + let currentModel: string | null = null; + // Live config ref + the cost-saver (research-bloat compaction) toggle. The UI + // flips this; we mutate the running config so the loop picks it up next turn. + let agentConfig: AgentConfig | null = null; + let costSaver = userConfig['cost-saver'] !== 'false'; + let inputQueue: string[] = []; + let inputResolver: ((v: string | null) => void) | null = null; + let abortFn: (() => void) | null = null; + + // The socket + correlation id for the in-flight turn (single-window). + let activeWs: WebSocket | null = null; + let activeTurnId: string | null = null; + // We sometimes inject helper commands (`/model …`, `/clear`) as their own + // turns ahead of the real prompt. Each ends with its own turn_done — which + // would emit agent.done and clear activeTurnId, killing the real prompt's + // stream. This counter swallows each injected turn's events (text + turn_done) + // so the real prompt streams next under the same activeTurnId. It's a counter, + // not a bool, because a single send can inject more than one command. + let suppressTurns = 0; + // The client conversation id the running agent history belongs to. When a turn + // arrives for a different conversation we /clear the history so separate + // sidebar chats don't bleed context (the server runs one long-lived session). + let currentConvId: string | null = null; + const stepIds = new Map(); + const stepLabels = new Map(); + const stepDetails = new Map(); + let stepSeq = 0; + + function getUserInput(): Promise { + return new Promise((resolve) => { + if (inputQueue.length > 0) { + resolve(inputQueue.shift()!); + return; + } + inputResolver = resolve; + }); + } + function pushInput(text: string): void { + if (inputResolver) { + const r = inputResolver; + inputResolver = null; + r(text); + } else { + inputQueue.push(text); + } + } + + function emit(kind: string, payload: unknown): void { + if (activeWs && activeTurnId) send(activeWs, activeTurnId, kind, payload); + } + + // ── Wallet balance (cached client) + post-turn broadcast ── + let walletClient: Awaited> | ReturnType | null = null; + async function getWallet() { + if (walletClient) return walletClient; + const c = chain === 'solana' + ? await setupAgentSolanaWallet({ silent: true }) + : setupAgentWallet({ silent: true }); + walletClient = c; + return c; + } + async function fetchBalanceUsd(): Promise { + try { + const client = await getWallet(); + return await retryFetchBalance(() => client.getBalance()); + } catch { return undefined; } + } + // After each turn, push the fresh balance to the UI (settlement may have + // changed it) so the sidebar pill + wallet page update live and stay in sync. + // Broadcast with a non-turn id so it reaches the client's global listeners. + function broadcastWalletAfterTurn(): void { + const ws = activeWs; + if (!ws) return; + void fetchBalanceUsd().then((balanceUsd) => { + if (balanceUsd != null) send(ws, 'wallet', 'wallet.event', { balanceUsd }); + }); + } + + function onEvent(event: StreamEvent): void { + // Injected helper turn (/model, /clear): drop its output and end-of-turn so + // it neither shows in the chat nor closes the real prompt's stream. + if (suppressTurns > 0) { + if (event.kind === 'turn_done') suppressTurns--; + return; + } + switch (event.kind) { + case 'text_delta': + // Drop internal compaction status lines (🗜 …) — they're CLI ops noise, + // not part of the answer, and shouldn't render in the desktop chat. + if (/^\s*\*?🗜/.test(event.text)) break; + emit('agent.text', { sessionId: '', text: event.text }); + break; + case 'capability_start': { + let sid = stepIds.get(event.id); + if (sid == null) { sid = ++stepSeq; stepIds.set(event.id, sid); } + const label = labelFor(event.name); + stepLabels.set(event.id, label); + // The per-call detail (the tool's key argument — query, prompt, symbol…) + // shown as small text next to the tool so you see WHAT it's doing. + const detail = event.preview?.trim() || ''; + if (detail) stepDetails.set(event.id, detail); + emit('agent.step', { sessionId: '', stepId: sid, label, detail, state: 'run' }); + break; + } + case 'capability_done': { + const sid = stepIds.get(event.id) ?? ++stepSeq; + // Keep the original label on completion — sending '' here is what made + // finished steps render as a bare checkmark with no text. + emit('agent.step', { sessionId: '', stepId: sid, label: stepLabels.get(event.id) ?? '', detail: stepDetails.get(event.id) ?? '', state: 'done' }); + const images = event.result?.images; + if (images && images.length) { + emit('agent.tool_result', { + sessionId: '', + toolCallId: event.id, + preview: event.result.output ?? '', + isError: event.result.isError, + artifacts: images.map((im) => ({ + path: `data:${im.mediaType};base64,${im.base64}`, + mediaType: im.mediaType, + })), + }); + } + // MusicGen / media tools save a local file and report its path in the + // output text. Surface generated audio (and stand-alone video/image + // files) as a playable artifact served over the /file route. + const out = event.result?.output ?? ''; + const fileMatch = out.match(/(\/[^\s'"]*\.(?:mp3|wav|m4a|ogg|flac|mp4|webm))/i); + if (fileMatch) { + const filePath = fileMatch[1]; + const ext = filePath.toLowerCase().split('.').pop() || ''; + const mediaType = + ext === 'mp4' || ext === 'webm' ? `video/${ext}` : + ext === 'mp3' ? 'audio/mpeg' : + ext === 'm4a' ? 'audio/mp4' : `audio/${ext}`; + emit('agent.tool_result', { + sessionId: '', + toolCallId: event.id, + preview: '', + artifacts: [{ path: `http://127.0.0.1:${port}/file?path=${encodeURIComponent(filePath)}`, mediaType }], + }); + } + break; + } + case 'turn_done': + if (event.reason === 'completed') { + emit('agent.done', { sessionId: '', costUsd: 0 }); + } else if (event.error) { + emit('agent.error', { sessionId: '', message: event.error }); + } else { + emit('agent.done', { sessionId: '', costUsd: 0 }); + } + activeTurnId = null; + stepIds.clear(); + stepLabels.clear(); + stepDetails.clear(); + broadcastWalletAfterTurn(); + break; + // thinking_delta / capability_input_delta / capability_progress / usage: + // not surfaced to the UI yet. + default: + break; + } + } + + async function ensureSession(model: string): Promise { + if (sessionStarted) return; + sessionStarted = true; + currentModel = model; + const systemInstructions = assembleInstructions(workDir, model); + const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities, model); + try { + const { registerMoAConfig } = await import('../tools/moa.js'); + registerMoAConfig(apiUrl, chain, model); + } catch { /* MoA optional */ } + const capabilities = [...allCapabilities, subAgent]; + + const config: AgentConfig = { + model, + apiUrl, + chain, + systemInstructions, + capabilities, + maxTurns: 100, + workingDir: workDir, + permissionMode: 'trust', // the desktop UI has no permission prompt yet + debug: !!debug, + showPrefetchStatus: false, + costSaver, + }; + agentConfig = config; + + interactiveSession(config, getUserInput, onEvent, (abort) => { abortFn = abort; }) + .catch((err) => { + if (activeWs && activeTurnId) { + send(activeWs, activeTurnId, 'agent.error', { sessionId: '', message: err instanceof Error ? err.message : String(err) }); + } + }) + .finally(() => { sessionStarted = false; abortFn = null; }); + } + + // ── RPC handlers ── + async function handle(ws: WebSocket, msg: ClientMsg): Promise { + const { id, kind, payload } = msg; + const p = (payload ?? {}) as Record; + switch (kind) { + case 'session.list': { + const metas = listSessions(); + send(ws, id, 'response', { + sessions: metas.map((m) => ({ + id: m.id, + title: `${m.model} · ${m.id.slice(0, 6)}`, + createdAt: m.createdAt, + updatedAt: m.updatedAt, + messageCount: m.messageCount ?? 0, + lastModel: m.model, + })), + }); + break; + } + case 'session.load': { + const history = loadSessionHistory(String(p.id ?? '')); + const messages = history + .filter((d) => d.role === 'user' || d.role === 'assistant') + .map((d) => ({ role: d.role as 'user' | 'assistant', content: dialogueText(d.content), kind: 'text' as const })) + .filter((m) => m.content); + send(ws, id, 'response', { messages }); + break; + } + case 'wallet.info': { + try { + const client = await getWallet(); + const address = client.getWalletAddress(); + const balanceUsd = await fetchBalanceUsd(); // best-effort; undefined on failure + send(ws, id, 'response', { address, chain, balanceUsd }); + } catch (err) { + send(ws, id, 'error', { message: err instanceof Error ? err.message : 'wallet error' }); + } + break; + } + case 'wallet.tokens': { + // Holdings: native ETH + curated Base ERC-20s with a non-zero balance. + // Public RPC can't enumerate ALL tokens, so this is a known-token sweep. + try { + if (chain !== 'base') { send(ws, id, 'response', { tokens: [] }); break; } + const client = await getWallet(); + const address = await client.getWalletAddress(); + const tokens = await listBaseHoldings(address); + send(ws, id, 'response', { tokens }); + } catch (err) { + send(ws, id, 'error', { message: err instanceof Error ? err.message : 'tokens error' }); + } + break; + } + case 'history.load': { + // History is wallet-synced to the cloud (franklin.run, same as the web) + // with a local file (~/.blockrun) as cache + offline fallback. Cloud is + // the source of truth when reachable; otherwise we serve the local file. + const file = path.join(BLOCKRUN_DIR, 'franklin-desktop-history.json'); + const readLocal = (): unknown[] => { + try { + if (fs.existsSync(file)) { const p2 = JSON.parse(fs.readFileSync(file, 'utf-8')); if (Array.isArray(p2)) return p2; } + } catch { /* ignore */ } + return []; + }; + const writeLocal = (c: unknown[]) => { + try { fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); fs.writeFileSync(file, JSON.stringify(c), { mode: 0o600 }); } catch { /* ignore */ } + }; + try { + const local = readLocal(); + if (isCloudSyncEnabled()) { + try { + const cloud = await cloudList(); + if (cloud.length > 0) { + writeLocal(cloud); // refresh local cache + send(ws, id, 'response', { conversations: cloud }); + break; + } + // Cloud empty but we have local history → migrate it up. + if (local.length > 0) void cloudSync(local as CloudConversation[]).catch(() => {}); + } catch { /* offline / not-deployed → fall back to local */ } + } + send(ws, id, 'response', { conversations: local }); + } catch (err) { + send(ws, id, 'error', { message: err instanceof Error ? err.message : 'history load error' }); + } + break; + } + case 'history.save': { + try { + const conversations = Array.isArray(p.conversations) ? p.conversations : []; + // Local file = instant durable cache; cloud = best-effort wallet sync. + fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); + fs.writeFileSync(path.join(BLOCKRUN_DIR, 'franklin-desktop-history.json'), JSON.stringify(conversations), { mode: 0o600 }); + if (isCloudSyncEnabled()) void cloudSync(conversations as CloudConversation[]).catch(() => {}); + send(ws, id, 'response', { ok: true }); + } catch (err) { + send(ws, id, 'error', { message: err instanceof Error ? err.message : 'history save error' }); + } + break; + } + case 'wallet.swaps': { + try { + send(ws, id, 'response', { swaps: readSwaps(100) }); + } catch (err) { + send(ws, id, 'error', { message: err instanceof Error ? err.message : 'swaps error' }); + } + break; + } + case 'wallet.spend': { + // Real spend, sourced from the x402 settlement ledger (cost_log.jsonl) — + // the same truth the CLI dashboard uses. Covers BOTH model calls and + // paid tools (web search, image gen, …), not a token estimate. + try { + const rows = loadSdkSettlements(); + const byModel: Record = {}; + let totalUsd = 0; + for (const r of rows) { + const key = r.model || r.endpoint || 'unknown'; + const b = byModel[key] ?? { usd: 0, count: 0 }; + b.usd += r.costUsd; + b.count += 1; + byModel[key] = b; + totalUsd += r.costUsd; + } + const receipts = [...rows] + .sort((a, b) => b.ts - a.ts) + .slice(0, 100) + .map((r) => ({ ts: r.ts, model: r.model || r.endpoint || 'unknown', usd: r.costUsd })); + send(ws, id, 'response', { totalUsd, requests: rows.length, byModel, receipts }); + } catch (err) { + send(ws, id, 'error', { message: err instanceof Error ? err.message : 'spend error' }); + } + break; + } + case 'models.list': { + try { + const models = await getModelsByCategory('chat'); + const mapped = models.map((m) => { + const provider = providerLabel(m.id, m.owned_by); + return { + id: m.id, + label: m.name, + free: m.billing_mode === 'free', + group: provider, + provider, + contextWindow: m.context_window, + }; + }); + // Group by provider (PROVIDER_ORDER first, then alphabetical); free + // models surface first within each provider. The picker renders + // consecutive same-`group` items as one section, so sort accordingly. + const rank = (g: string) => { const i = PROVIDER_ORDER.indexOf(g); return i < 0 ? 999 : i; }; + mapped.sort((a, b) => + rank(a.group) - rank(b.group) || + a.group.localeCompare(b.group) || + (a.free === b.free ? 0 : a.free ? -1 : 1) || + a.label.localeCompare(b.label), + ); + send(ws, id, 'response', { models: mapped }); + } catch (err) { + send(ws, id, 'error', { message: err instanceof Error ? err.message : 'models error' }); + } + break; + } + case 'agent.send': { + const text = String(p.text ?? '').trim(); + if (!text) { send(ws, id, 'agent.error', { sessionId: '', message: 'empty input' }); break; } + activeWs = ws; + activeTurnId = id; + stepIds.clear(); + // A non-empty model means "switch the chat model". Media turns send no + // model (the image/video model is a TOOL parameter baked into the + // prompt, NOT the chat model — switching the chat model to an image + // model breaks the turn), so we keep the current chat model for them. + const desiredModel = p.model ? String(p.model) : null; + const clientConvId = p.convId ? String(p.convId) : null; + await ensureSession(desiredModel || userConfig['default-model'] || FREE_DEFAULT_MODEL); + // Conversation switch → wipe the agent's history so a new/other chat + // doesn't inherit the previous one's context. Only when we already had a + // different conversation loaded (not on the very first turn). + if (clientConvId && currentConvId && clientConvId !== currentConvId) { + suppressTurns++; // swallow the injected /clear turn (see onEvent) + pushInput('/clear'); + } + if (clientConvId) currentConvId = clientConvId; + if (desiredModel && currentModel && desiredModel !== currentModel) { + suppressTurns++; // swallow the injected /model turn (see onEvent) + pushInput(`/model ${desiredModel}`); + currentModel = desiredModel; + } + pushInput(text); + break; + } + case 'settings.get': + send(ws, id, 'response', { costSaver }); + break; + case 'settings.set': { + if (typeof p.costSaver === 'boolean') { + costSaver = p.costSaver; + if (agentConfig) agentConfig.costSaver = costSaver; // live-update the running session + setConfigValue('cost-saver', costSaver ? 'true' : 'false'); // persist across restarts + } + send(ws, id, 'response', { costSaver }); + break; + } + case 'agent.cancel': + if (abortFn) abortFn(); + break; + case 'agent.permissionResponse': + // permissionMode is 'trust' — nothing to unblock. + break; + default: + send(ws, id, 'error', { message: `Unknown kind: ${kind}` }); + } + } + + // ── HTTP + WS ── + // HTTP: a /file route streams a generated media file (audio/video/image) so + // the renderer can play it. The path param is confined to media files under + // the session work dir (plus FRANKLIN_SERVE_FILE_ROOTS extras) — NOT the + // whole filesystem — and the request must pass the Origin gate. Otherwise a + // hostile page could fetch wallet/key files off the loopback port. + const fileRoots = [ + workDir, + path.join(BLOCKRUN_DIR, 'content'), + ...(process.env.FRANKLIN_SERVE_FILE_ROOTS || '').split(',').map((s) => s.trim()).filter(Boolean), + ].map((r) => { try { return fs.realpathSync(r); } catch { return null; } }).filter((r): r is string => !!r); + const MEDIA_MIME: Record = { + mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac', + mp4: 'video/mp4', webm: 'video/webm', + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', + }; + const httpServer = http.createServer((req, res) => { + try { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + const origin = req.headers.origin; + if (url.pathname === '/file') { + if (!isOriginAllowed(origin) || !tokenOk(url)) { res.writeHead(403); res.end(); return; } + const p = url.searchParams.get('path') || ''; + if (!p || !fs.existsSync(p) || !fs.statSync(p).isFile()) { res.writeHead(404); res.end(); return; } + // Resolve symlinks before the prefix check so a link can't escape a root. + const real = fs.realpathSync(p); + const inRoot = fileRoots.some((root) => real === root || real.startsWith(root + path.sep)); + const ext = real.toLowerCase().split('.').pop() || ''; + const mime = MEDIA_MIME[ext]; + if (!inRoot || !mime) { res.writeHead(403); res.end(); return; } + res.writeHead(200, { + 'Content-Type': mime, + // Reflect the (already vetted) origin instead of `*` so arbitrary + // sites can't read the bytes cross-origin. + ...(origin && origin !== 'null' ? { 'Access-Control-Allow-Origin': origin } : {}), + }); + fs.createReadStream(real).pipe(res); + return; + } + } catch { /* fall through to 404 */ } + res.writeHead(404); + res.end(); + }); + const wss = new WebSocket.Server({ + server: httpServer, + path: '/agent', + // Same gate as /file: refuse upgrades from non-allowlisted browser origins + // (and require the token when one is configured). See isOriginAllowed. + verifyClient: (info: { origin?: string; req: http.IncomingMessage }) => { + const url = new URL(info.req.url || '/', 'http://127.0.0.1'); + const allowed = isOriginAllowed(info.origin || info.req.headers.origin) && tokenOk(url); + if (!allowed && debug) console.log(`[serve] rejected WS upgrade from origin=${info.origin ?? 'n/a'}`); + return allowed; + }, + }); + + wss.on('connection', (ws: WebSocket) => { + ws.on('message', (raw: Buffer) => { + let msg: ClientMsg; + try { msg = JSON.parse(raw.toString()); } catch { return; } + handle(ws, msg).catch((err) => { + send(ws, msg.id, 'error', { message: err instanceof Error ? err.message : String(err) }); + }); + }); + }); + + await new Promise((resolve) => { + httpServer.listen(port, '127.0.0.1', () => { + // eslint-disable-next-line no-console + console.log(`Franklin agent server on ws://127.0.0.1:${port}/agent (chain: ${chain}, workdir: ${workDir})`); + resolve(); + }); + }); +} diff --git a/src/stats/swap-log.ts b/src/stats/swap-log.ts new file mode 100644 index 00000000..26aaac90 --- /dev/null +++ b/src/stats/swap-log.ts @@ -0,0 +1,44 @@ +/** + * Swap ledger — one line per executed on-chain swap, at ~/.blockrun/swaps.jsonl. + * The swap tools (0x on Base, Jupiter on Solana) append here on success so the + * desktop wallet view can show a "swaps" history with explorer links. Mirrors + * the JSONL pattern of cost-log / audit. All writes are best-effort. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { BLOCKRUN_DIR } from '../config.js'; + +const SWAP_FILE = path.join(BLOCKRUN_DIR, 'swaps.jsonl'); + +export interface SwapRow { + ts: number; // unix ms + chain: 'base' | 'solana'; + dex: string; // '0x' | 'jupiter' | … + sellSym: string; + sellAmount: number; + buySym: string; + buyAmount: number; + txHash: string; + explorer?: string; // full explorer URL +} + +export function appendSwap(row: SwapRow): void { + try { + fs.mkdirSync(BLOCKRUN_DIR, { recursive: true }); + fs.appendFileSync(SWAP_FILE, JSON.stringify(row) + '\n', { mode: 0o600 }); + } catch { /* best-effort */ } +} + +export function readSwaps(limit = 100): SwapRow[] { + try { + const lines = fs.readFileSync(SWAP_FILE, 'utf-8').trim().split('\n').filter(Boolean); + const rows: SwapRow[] = []; + for (const line of lines) { + try { rows.push(JSON.parse(line) as SwapRow); } catch { /* skip bad line */ } + } + return rows.sort((a, b) => b.ts - a.ts).slice(0, limit); + } catch { + return []; + } +} diff --git a/src/tools/zerox-base.ts b/src/tools/zerox-base.ts index 49ad7b3e..f39127d0 100644 --- a/src/tools/zerox-base.ts +++ b/src/tools/zerox-base.ts @@ -43,6 +43,7 @@ import { getOrCreateWallet } from '@blockrun/llm'; import { loadConfig } from '../commands/config.js'; import { loadChain, API_URLS, VERSION } from '../config.js'; +import { appendSwap } from '../stats/swap-log.js'; import type { CapabilityHandler, ExecutionScope } from '../agent/types.js'; // ─── BlockRun affiliate identity on Base ───────────────────────────────── @@ -549,9 +550,42 @@ async function executeBase0xSwap( liveSwapCount += 1; const explorer = `https://basescan.org/tx/${txHash}`; + // Confirm on-chain before recording: a submitted tx can still revert (e.g. + // slippage floor exceeded), and the swap log feeds the desktop wallet + // history — same confirmed-only gating as the gasless tool. Base blocks land + // in ~2s, so the wait is cheap. + let confirmed = false; + try { + const receipt = await client.waitForTransactionReceipt({ hash: txHash, timeout: 60_000 }); + if (receipt.status !== 'success') { + return { + output: + `Swap reverted on-chain (likely the slippage floor was exceeded — no tokens moved, only gas was spent).\n` + + `Tx hash: ${txHash}\n${explorer}`, + isError: true, + }; + } + confirmed = true; + } catch { /* receipt wait timed out / RPC hiccup — report submitted, don't record */ } + if (confirmed) { + // Record the swap so the desktop wallet can show a history (best-effort). + try { + appendSwap({ + ts: Date.now(), + chain: 'base', + dex: '0x', + sellSym: symbolFor(quote.sellToken), + sellAmount: Number(formatUnits(BigInt(quote.sellAmount), decimalsFor(quote.sellToken))), + buySym: symbolFor(quote.buyToken), + buyAmount: Number(formatUnits(BigInt(quote.buyAmount), decimalsFor(quote.buyToken))), + txHash, + explorer, + }); + } catch { /* best-effort */ } + } return { output: [ - '✓ Swap executed on Base.', + confirmed ? '✓ Swap executed on Base.' : '✓ Swap submitted on Base (confirmation pending — check the explorer).', formatQuoteText(quote), `Tx hash: ${txHash}`, explorer, diff --git a/src/tools/zerox-gasless.ts b/src/tools/zerox-gasless.ts index dd2bd885..fb32f382 100644 --- a/src/tools/zerox-gasless.ts +++ b/src/tools/zerox-gasless.ts @@ -32,6 +32,7 @@ import { getOrCreateWallet } from '@blockrun/llm'; import { loadConfig } from '../commands/config.js'; import { loadChain, API_URLS, VERSION } from '../config.js'; +import { appendSwap } from '../stats/swap-log.js'; import { logger } from '../logger.js'; import type { CapabilityHandler, ExecutionScope } from '../agent/types.js'; @@ -537,6 +538,22 @@ async function executeBase0xGaslessSwap( ? `✗ Swap failed: ${final.reason ?? 'unknown reason'}` : `⏳ Still pending after ${MAX_STATUS_POLL_MS / 1000}s — relayer is backlogged. Check status later via /v1/zerox/gasless/status/${submitRes.tradeHash}.`; + if ((final.status === 'confirmed' || final.status === 'succeeded') && onChainHash) { + try { + appendSwap({ + ts: Date.now(), + chain: 'base', + dex: '0x', + sellSym: symbolFor(quote.sellToken), + sellAmount: Number(formatUnits(BigInt(quote.sellAmount), decimalsFor(quote.sellToken))), + buySym: symbolFor(quote.buyToken), + buyAmount: Number(formatUnits(BigInt(quote.buyAmount), decimalsFor(quote.buyToken))), + txHash: onChainHash, + explorer: explorer ?? undefined, + }); + } catch { /* best-effort */ } + } + const lines: string[] = [ statusLine, formatGaslessQuoteText(quote),