diff --git a/AGENTS.md b/AGENTS.md index 3a840603..726193b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ - packages/ai/README.md - packages/tui/README.md - packages/agent/README.md - - packages/coding-agent.md + - packages/coding-agent/README.md + - packages/mom/README.md - packages/pods/README.md - packages/web-ui/README.md - We must NEVER have type `any` anywhere, unless absolutely, positively necessary. diff --git a/README.md b/README.md index 318ad518..1000e0fb 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Tools for building AI agents and managing LLM deployments. | **[@mariozechner/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) | | **[@mariozechner/pi-agent](packages/agent)** | Agent runtime with tool calling and state management | | **[@mariozechner/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI | +| **[@mariozechner/pi-mom](packages/mom)** | Slack bot that delegates messages to the pi coding agent | | **[@mariozechner/pi-tui](packages/tui)** | Terminal UI library with differential rendering | | **[@mariozechner/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces | | **[@mariozechner/pi-proxy](packages/proxy)** | CORS proxy for browser-based LLM API calls | diff --git a/package-lock.json b/package-lock.json index 44d0a1cd..8f1106bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,35 @@ "node": ">=20.0.0" } }, + "node_modules/@anthropic-ai/sandbox-runtime": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.16.tgz", + "integrity": "sha512-I2Us7dRvwCJkqcImrqP7NWrV5kHmKX7AumFDnznCjMd0hB5ZUzg9Is9SlxrMzHiUY2RndEaRLXCOJrUt8JnE4w==", + "license": "Apache-2.0", + "dependencies": { + "@pondwader/socks5-server": "^1.0.10", + "@types/lodash-es": "^4.17.12", + "commander": "^12.1.0", + "lodash-es": "^4.17.21", + "shell-quote": "^1.8.3", + "zod": "^3.24.1" + }, + "bin": { + "srt": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@anthropic-ai/sandbox-runtime/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.61.0", "license": "MIT", @@ -301,6 +330,10 @@ "resolved": "packages/coding-agent", "link": true }, + "node_modules/@mariozechner/pi-mom": { + "resolved": "packages/mom", + "link": true + }, "node_modules/@mariozechner/pi-proxy": { "resolved": "packages/proxy", "link": true @@ -422,6 +455,12 @@ "node": ">=14" } }, + "node_modules/@pondwader/socks5-server": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", + "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", + "license": "MIT" + }, "node_modules/@preact/signals-core": { "version": "1.12.1", "dev": true, @@ -447,6 +486,71 @@ "version": "0.34.41", "license": "MIT" }, + "node_modules/@slack/logger": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", + "integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18.0.0" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.5.tgz", + "integrity": "sha512-VaapvmrAifeFLAFaDPfGhEwwunTKsI6bQhYzxRXw7BSujZUae5sANO76WqlVsLXuhVtCVrBWPiS2snAQR2RHJQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4", + "@slack/web-api": "^7.10.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.19.0.tgz", + "integrity": "sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.12.0.tgz", + "integrity": "sha512-LrDxjYyqjeYYQGVdVZ6EYHunFmzveOr2pFpShr6TzW4KNFpdNNnpKekjtMg0PJlOsMibSySLGQqiBZQDasmRCA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/types": "^2.18.0", + "@types/node": ">=18.0.0", + "@types/retry": "0.12.0", + "axios": "^1.11.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/@tailwindcss/cli": { "version": "4.1.17", "dev": true, @@ -539,23 +643,52 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mime-types": { "version": "2.1.4", "license": "MIT" }, "node_modules/@types/node": { "version": "22.19.1", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "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/trusted-types": { "version": "2.0.7", "license": "MIT", "peer": true }, + "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/@typescript/native-preview": { "version": "7.0.0-dev.20251120.1", "dev": true, @@ -773,6 +906,23 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "license": "MIT" @@ -865,6 +1015,19 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/canvas": { "version": "3.2.0", "dev": true, @@ -1029,6 +1192,18 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "8.3.0", "dev": true, @@ -1168,6 +1343,15 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "dev": true, @@ -1190,6 +1374,20 @@ "jszip": ">=3.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -1225,11 +1423,56 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "dev": true, @@ -1286,6 +1529,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "dev": true, @@ -1372,6 +1621,26 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "license": "ISC", @@ -1386,6 +1655,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "license": "MIT", @@ -1413,6 +1719,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "7.1.3", "license": "Apache-2.0", @@ -1456,6 +1771,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.0", "dev": true, @@ -1516,6 +1868,18 @@ "node": ">=14" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "dev": true, @@ -1539,6 +1903,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "dev": true, @@ -1616,6 +2019,12 @@ "dev": true, "license": "ISC" }, + "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-extglob": { "version": "2.1.1", "dev": true, @@ -1650,6 +2059,18 @@ "node": ">=0.12.0" } }, + "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/isarray": { "version": "1.0.0", "license": "MIT" @@ -1850,6 +2271,12 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "dev": true, @@ -1884,6 +2311,15 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -2089,6 +2525,62 @@ } } }, + "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-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/package-json-from-dist": { "version": "1.0.1", "license": "BlueOak-1.0.0" @@ -2217,6 +2709,12 @@ "version": "2.0.1", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "dev": true, @@ -2276,6 +2774,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "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/rimraf": { "version": "5.0.10", "license": "ISC", @@ -2451,7 +2958,6 @@ }, "node_modules/shell-quote": { "version": "1.8.3", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2851,7 +3357,6 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "dev": true, "license": "MIT" }, "node_modules/util-deprecate": { @@ -3364,6 +3869,48 @@ "dev": true, "license": "MIT" }, + "packages/mom": { + "name": "@mariozechner/pi-mom", + "version": "0.9.3", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.16", + "@mariozechner/pi-agent-core": "^0.9.3", + "@mariozechner/pi-ai": "^0.9.3", + "@sinclair/typebox": "^0.34.0", + "@slack/socket-mode": "^2.0.0", + "@slack/web-api": "^7.0.0", + "diff": "^8.0.2" + }, + "bin": { + "mom": "dist/main.js" + }, + "devDependencies": { + "@types/diff": "^7.0.2", + "@types/node": "^24.3.0", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "packages/mom/node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/mom/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "packages/pods": { "name": "@mariozechner/pi", "version": "0.9.3", diff --git a/package.json b/package.json index 090e8a64..b2221431 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent-core && npm run build -w @mariozechner/pi-coding-agent && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi", - "dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui,proxy\" --prefix-colors \"cyan,yellow,red,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-agent-core\" \"npm run dev -w @mariozechner/pi-coding-agent\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"", + "build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent-core && npm run build -w @mariozechner/pi-coding-agent && npm run build -w @mariozechner/pi-mom && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi", + "dev": "concurrently --names \"ai,agent,coding-agent,mom,web-ui,tui,proxy\" --prefix-colors \"cyan,yellow,red,white,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-agent-core\" \"npm run dev -w @mariozechner/pi-coding-agent\" \"npm run dev -w @mariozechner/pi-mom\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"", "dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"npm run dev:tsc -w @mariozechner/pi-ai\" \"npm run dev:tsc -w @mariozechner/pi-web-ui\"", "check": "biome check --write . && npm run check --workspaces && tsgo --noEmit", "test": "npm run test --workspaces --if-present", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 4ffbaeb7..9cbcff8e 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5000,23 +5000,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "cohere/command-r-plus-08-2024": { - id: "cohere/command-r-plus-08-2024", - name: "Cohere: Command R+ (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, "cohere/command-r-08-2024": { id: "cohere/command-r-08-2024", name: "Cohere: Command R (08-2024)", @@ -5034,6 +5017,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/command-r-plus-08-2024": { + id: "cohere/command-r-plus-08-2024", + name: "Cohere: Command R+ (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -5102,6 +5102,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -5136,23 +5153,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5578,23 +5578,6 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-0314": { id: "openai/gpt-4-0314", name: "OpenAI: GPT-4 (older v0314)", @@ -5629,6 +5612,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/mom/CHANGELOG.md b/packages/mom/CHANGELOG.md new file mode 100644 index 00000000..4ce417ca --- /dev/null +++ b/packages/mom/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [Unreleased] + +### Added + +- Initial scaffold diff --git a/packages/mom/README.md b/packages/mom/README.md new file mode 100644 index 00000000..e54561ce --- /dev/null +++ b/packages/mom/README.md @@ -0,0 +1,3 @@ +# @mariozechner/pi-mom + +Slack bot that delegates channel messages to a pi coding agent instance. diff --git a/packages/mom/docs/sandbox.md b/packages/mom/docs/sandbox.md new file mode 100644 index 00000000..53ebd9f8 --- /dev/null +++ b/packages/mom/docs/sandbox.md @@ -0,0 +1,95 @@ +# Mom Sandbox Implementation + +## Overview + +Mom uses [@anthropic-ai/sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) to restrict what the bash tool can do at the OS level. + +## Current Implementation + +Located in `src/sandbox.ts`: + +```typescript +import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime"; + +const runtimeConfig: SandboxRuntimeConfig = { + network: { + allowedDomains: [], // Currently no network - should be ["*"] for full access + deniedDomains: [], + }, + filesystem: { + denyRead: ["~/.ssh", "~/.aws", ...], // Sensitive paths + allowWrite: [channelDir, scratchpadDir], // Only mom's folders + denyWrite: [], + }, +}; + +await SandboxManager.initialize(runtimeConfig); +const sandboxedCommand = await SandboxManager.wrapWithSandbox(command); +``` + +## Key Limitation: Read Access + +**Read is deny-only** - allowed everywhere by default. We can only deny specific paths, NOT allow only specific paths. + +This means: +- ❌ Cannot say "only allow reads from channelDir and scratchpadDir" +- ✅ Can say "deny reads from ~/.ssh, ~/.aws, etc." + +The bash tool CAN read files outside the mom data folder. We mitigate by denying sensitive directories. + +## Write Access + +**Write is allow-only** - denied everywhere by default. This works perfectly for our use case: +- Only `channelDir` and `scratchpadDir` can be written to +- Everything else is blocked + +## Network Access + +- `allowedDomains: []` = no network access +- `allowedDomains: ["*"]` = full network access +- `allowedDomains: ["github.com", "*.github.com"]` = specific domains + +## How It Works + +- **macOS**: Uses `sandbox-exec` with Seatbelt profiles +- **Linux**: Uses `bubblewrap` for containerization + +The sandbox wraps commands - `SandboxManager.wrapWithSandbox("ls")` returns a modified command that runs inside the sandbox. + +## Files + +- `src/sandbox.ts` - Sandbox initialization and command wrapping +- `src/tools/bash.ts` - Uses `wrapCommand()` before executing + +## Usage in Agent + +```typescript +// In runAgent(): +await initializeSandbox({ channelDir, scratchpadDir }); +try { + // ... run agent +} finally { + await resetSandbox(); +} +``` + +## TODO + +1. **Update network config** - Change `allowedDomains: []` to `["*"]` for full network access +2. **Consider stricter read restrictions** - Current approach denies known sensitive paths but allows reads elsewhere +3. **Test on Linux** - Requires `bubblewrap` and `socat` installed + +## Dependencies + +macOS: +- `ripgrep` (brew install ripgrep) + +Linux: +- `bubblewrap` (apt install bubblewrap) +- `socat` (apt install socat) +- `ripgrep` (apt install ripgrep) + +## Reference + +- [sandbox-runtime README](https://github.com/anthropic-experimental/sandbox-runtime) +- [Claude Code Sandboxing Docs](https://docs.claude.com/en/docs/claude-code/sandboxing) diff --git a/packages/mom/docs/slack-bot-minimal-guide.md b/packages/mom/docs/slack-bot-minimal-guide.md new file mode 100644 index 00000000..12462e7a --- /dev/null +++ b/packages/mom/docs/slack-bot-minimal-guide.md @@ -0,0 +1,399 @@ +# Minimal Slack Bot Setup (No Web Server, WebSocket Only) + +Here's how to connect your Node.js agent to Slack using **Socket Mode** - no Express, no HTTP server, just WebSockets and callbacks. + +--- + +## 1. Dependencies + +```bash +npm install @slack/socket-mode @slack/web-api +``` + +That's it. Two packages: +- `@slack/socket-mode` - Receives events via WebSocket +- `@slack/web-api` - Sends messages back to Slack + +--- + +## 2. Get Your Tokens + +You need **TWO tokens**: + +### A. Bot Token (`xoxb-...`) +1. Go to https://api.slack.com/apps +2. Create app → "From scratch" +3. Click "OAuth & Permissions" in sidebar +4. Add **Bot Token Scopes** (all 16): + ``` + app_mentions:read + channels:history + channels:join + channels:read + chat:write + files:read + files:write + groups:history + groups:read + im:history + im:read + im:write + mpim:history + mpim:read + mpim:write + users:read + ``` +5. Click "Install to Workspace" at top +6. Copy the **Bot User OAuth Token** (starts with `xoxb-`) + +### B. App-Level Token (`xapp-...`) +1. In same app, click "Basic Information" in sidebar +2. Scroll to "App-Level Tokens" +3. Click "Generate Token and Scopes" +4. Name it whatever (e.g., "socket-token") +5. Add scope: `connections:write` +6. Click "Generate" +7. Copy the token (starts with `xapp-`) + +--- + +## 3. Enable Socket Mode + +1. Go to https://api.slack.com/apps → select your app +2. Click **"Socket Mode"** in sidebar +3. Toggle **"Enable Socket Mode"** to ON +4. This routes your app's interactions and events over WebSockets instead of public HTTP endpoints +5. Done - no webhook URL needed! + +**Note:** Socket Mode is intended for internal apps in development or behind a firewall. Not for apps distributed via Slack Marketplace. + +--- + +## 4. Enable Direct Messages + +1. Go to https://api.slack.com/apps → select your app +2. Click **"App Home"** in sidebar +3. Scroll to **"Show Tabs"** section +4. Check **"Allow users to send Slash commands and messages from the messages tab"** +5. Save + +--- + +## 5. Subscribe to Events + +1. Go to https://api.slack.com/apps → select your app +2. Click **"Event Subscriptions"** in sidebar +3. Toggle **"Enable Events"** to ON +4. **Important:** No Request URL needed (Socket Mode handles this) +5. Expand **"Subscribe to bot events"** +6. Click **"Add Bot User Event"** and add: + - `app_mention` (required - to see when bot is mentioned) + - `message.channels` (required - to log all channel messages for context) + - `message.groups` (optional - to see private channel messages) + - `message.im` (required - to see DMs) +7. Click **"Save Changes"** at bottom + +--- + +## 6. Store Tokens + +Create `.env` file: + +```bash +SLACK_BOT_TOKEN=xoxb-your-bot-token-here +SLACK_APP_TOKEN=xapp-your-app-token-here +``` + +Add to `.gitignore`: + +```bash +echo ".env" >> .gitignore +``` + +--- + +## 7. Minimal Working Code + +```javascript +require('dotenv').config(); +const { SocketModeClient } = require('@slack/socket-mode'); +const { WebClient } = require('@slack/web-api'); + +const socketClient = new SocketModeClient({ + appToken: process.env.SLACK_APP_TOKEN +}); + +const webClient = new WebClient(process.env.SLACK_BOT_TOKEN); + +// Listen for app mentions (@mom do something) +socketClient.on('app_mention', async ({ event, ack }) => { + try { + // Acknowledge receipt + await ack(); + + console.log('Mentioned:', event.text); + console.log('Channel:', event.channel); + console.log('User:', event.user); + + // Process with your agent + const response = await yourAgentFunction(event.text); + + // Send response + await webClient.chat.postMessage({ + channel: event.channel, + text: response + }); + } catch (error) { + console.error('Error:', error); + } +}); + +// Start the connection +(async () => { + await socketClient.start(); + console.log('⚡️ Bot connected and listening!'); +})(); + +// Your existing agent logic +async function yourAgentFunction(text) { + // Your code here + return "I processed: " + text; +} +``` + +**That's it. No web server. Just run it:** + +```bash +node bot.js +``` + +--- + +## 8. Listen to ALL Events (Not Just Mentions) + +If you want to see every message in channels/DMs the bot is in: + +```javascript +// Listen to all Slack events +socketClient.on('slack_event', async ({ event, body, ack }) => { + await ack(); + + console.log('Event type:', event.type); + console.log('Event data:', event); + + if (event.type === 'message' && event.subtype === undefined) { + // Regular message (not bot message, not edited, etc.) + console.log('Message:', event.text); + console.log('Channel:', event.channel); + console.log('User:', event.user); + + // Your logic here + } +}); +``` + +--- + +## 9. Common Operations + +### Send a message +```javascript +await webClient.chat.postMessage({ + channel: 'C12345', // or channel ID from event + text: 'Hello!' +}); +``` + +### Send a DM +```javascript +// Open DM channel with user +const result = await webClient.conversations.open({ + users: 'U12345' // user ID +}); + +// Send message to that DM +await webClient.chat.postMessage({ + channel: result.channel.id, + text: 'Hey there!' +}); +``` + +### List channels +```javascript +const channels = await webClient.conversations.list({ + types: 'public_channel,private_channel' +}); +console.log(channels.channels); +``` + +### Get channel members +```javascript +const members = await webClient.conversations.members({ + channel: 'C12345' +}); +console.log(members.members); // Array of user IDs +``` + +### Get user info +```javascript +const user = await webClient.users.info({ + user: 'U12345' +}); +console.log(user.user.name); +console.log(user.user.real_name); +``` + +### Join a channel +```javascript +await webClient.conversations.join({ + channel: 'C12345' +}); +``` + +### Upload a file +```javascript +await webClient.files.uploadV2({ + channel_id: 'C12345', + file: fs.createReadStream('./file.pdf'), + filename: 'document.pdf', + title: 'My Document' +}); +``` + +--- + +## 10. Complete Example with Your Agent + +```javascript +require('dotenv').config(); +const { SocketModeClient } = require('@slack/socket-mode'); +const { WebClient } = require('@slack/web-api'); + +const socketClient = new SocketModeClient({ + appToken: process.env.SLACK_APP_TOKEN +}); + +const webClient = new WebClient(process.env.SLACK_BOT_TOKEN); + +// Your existing agent/AI/whatever +class MyAgent { + async process(message, context) { + // Your complex logic here + // context has: user, channel, etc. + return `Processed: ${message}`; + } +} + +const agent = new MyAgent(); + +// Handle mentions +socketClient.on('app_mention', async ({ event, ack }) => { + await ack(); + + try { + // Remove the @mention from text + const text = event.text.replace(/<@[A-Z0-9]+>/g, '').trim(); + + // Process with your agent + const response = await agent.process(text, { + user: event.user, + channel: event.channel + }); + + // Send response + await webClient.chat.postMessage({ + channel: event.channel, + text: response + }); + } catch (error) { + console.error('Error processing mention:', error); + + // Send error message + await webClient.chat.postMessage({ + channel: event.channel, + text: 'Sorry, something went wrong!' + }); + } +}); + +// Start +(async () => { + await socketClient.start(); + console.log('⚡️ Agent connected to Slack!'); +})(); +``` + +--- + +## 11. Available Event Types + +You subscribed to these in step 4: + +- `app_mention` - Someone @mentioned the bot +- `message` - Any message in a channel/DM the bot is in + +Event object structure: + +```javascript +{ + type: 'app_mention' or 'message', + text: 'the message text', + user: 'U12345', // who sent it + channel: 'C12345', // where it was sent + ts: '1234567890.123456' // timestamp +} +``` + +--- + +## 12. Advantages of Socket Mode + +✅ **No web server needed** - just run your script +✅ **No public URL needed** - works behind firewall +✅ **No ngrok** - works on localhost +✅ **Auto-reconnect** - SDK handles connection drops +✅ **Event-driven** - just listen to callbacks + +--- + +## 13. Disadvantages + +❌ Can't distribute to Slack App Directory (only for your workspace) +❌ Script must be running to receive messages (unlike webhooks) +❌ Max 10 concurrent connections per app + +--- + +## Important Notes + +1. **You MUST call `ack()`** on every event or Slack will retry +2. **Bot token** (`xoxb-`) is for sending messages +3. **App token** (`xapp-`) is for receiving events via WebSocket +4. **Connection is persistent** - your script stays running +5. **No URL validation** needed (unlike HTTP webhooks) + +--- + +## Troubleshooting + +### "invalid_auth" error +- Check you're using the right tokens +- Bot token for WebClient, App token for SocketModeClient + +### "missing_scope" error +- Make sure you added all 16 bot scopes +- Reinstall the app after adding scopes + +### Not receiving events +- Check Socket Mode is enabled +- Check you subscribed to events in "Event Subscriptions" +- Make sure bot is in the channel (or use `channels:join`) + +### Bot doesn't respond to mentions +- Must subscribe to `app_mention` event +- Bot must be installed to workspace +- Check `await ack()` is called + +--- + +That's it. No HTTP server bullshit. Just WebSockets and callbacks. diff --git a/packages/mom/package.json b/packages/mom/package.json new file mode 100644 index 00000000..77ee310b --- /dev/null +++ b/packages/mom/package.json @@ -0,0 +1,52 @@ +{ + "name": "@mariozechner/pi-mom", + "version": "0.9.3", + "description": "Slack bot that delegates messages to the pi coding agent", + "type": "module", + "bin": { + "mom": "dist/main.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "CHANGELOG.md" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "tsgo -p tsconfig.build.json && chmod +x dist/main.js", + "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", + "check": "tsgo --noEmit", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.16", + "@mariozechner/pi-agent-core": "^0.9.3", + "@mariozechner/pi-ai": "^0.9.3", + "@sinclair/typebox": "^0.34.0", + "@slack/socket-mode": "^2.0.0", + "@slack/web-api": "^7.0.0", + "diff": "^8.0.2" + }, + "devDependencies": { + "@types/diff": "^7.0.2", + "@types/node": "^24.3.0", + "typescript": "^5.7.3" + }, + "keywords": [ + "slack", + "bot", + "ai", + "agent" + ], + "author": "Mario Zechner", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/badlogic/pi-mono.git", + "directory": "packages/mom" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts new file mode 100644 index 00000000..d61847cb --- /dev/null +++ b/packages/mom/src/agent.ts @@ -0,0 +1,242 @@ +import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core"; +import { getModel } from "@mariozechner/pi-ai"; +import { existsSync, readFileSync, rmSync } from "fs"; +import { mkdtemp } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; + +import type { SlackContext } from "./slack.js"; +import type { ChannelStore } from "./store.js"; +import { momTools, setUploadFunction } from "./tools/index.js"; + +// Hardcoded model for now +const model = getModel("anthropic", "claude-opus-4-5"); + +export interface AgentRunner { + run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise; + abort(): void; +} + +function getAnthropicApiKey(): string { + const key = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; + if (!key) { + throw new Error("ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY must be set"); + } + return key; +} + +function getRecentMessages(channelDir: string, count: number): string { + const logPath = join(channelDir, "log.jsonl"); + if (!existsSync(logPath)) { + return "(no message history yet)"; + } + + const content = readFileSync(logPath, "utf-8"); + const lines = content.trim().split("\n").filter(Boolean); + const recentLines = lines.slice(-count); + + if (recentLines.length === 0) { + return "(no message history yet)"; + } + + return recentLines.join("\n"); +} + +function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMessages: string): string { + return `You are mom, a helpful Slack bot assistant. + +## Communication Style +- Be concise and professional +- Do not use emojis unless the user communicates informally with you +- Get to the point quickly +- If you need clarification, ask directly +- Use Slack's mrkdwn format (NOT standard Markdown): + - Bold: *text* (single asterisks) + - Italic: _text_ + - Strikethrough: ~text~ + - Code: \`code\` + - Code block: \`\`\`code\`\`\` + - Links: + - Do NOT use **double asterisks** or [markdown](links) + +## Channel Data +The channel's data directory is: ${channelDir} + +### Message History +- File: ${channelDir}/log.jsonl +- Format: One JSON object per line (JSONL) +- Each line has: {"ts", "user", "userName", "displayName", "text", "attachments", "isBot"} +- "ts" is the Slack timestamp +- "user" is the user ID, "userName" is their handle, "displayName" is their full name +- "attachments" is an array of {"original", "local"} where "local" is the path relative to the working directory +- "isBot" is true for bot responses + +### Recent Messages (last 50) +Below are the most recent messages. If you need more context, read ${channelDir}/log.jsonl directly. + +${recentMessages} + +### Attachments +Files shared in the channel are stored in: ${channelDir}/attachments/ +The "local" field in attachments points to these files. + +## Scratchpad +Your temporary working directory is: ${scratchpadDir} +Use this for any file operations. It will be deleted after you complete. + +## Tools +You have access to: read, edit, write, bash, attach tools. +- read: Read files +- edit: Edit files +- write: Write new files +- bash: Run shell commands +- attach: Attach a file to your response (share files with the user) + +Each tool requires a "label" parameter - this is a brief description of what you're doing that will be shown to the user. +Keep labels short and informative, e.g., "Reading message history" or "Searching for user's previous questions". + +## Guidelines +- Be concise and helpful +- If you need more conversation history beyond the recent messages above, read log.jsonl +- Use the scratchpad for any temporary work + +## CRITICAL +- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE. +`; +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.substring(0, maxLen - 3) + "..."; +} + +export function createAgentRunner(): AgentRunner { + let agent: Agent | null = null; + + return { + async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise { + // Create scratchpad + const scratchpadDir = await mkdtemp(join(tmpdir(), "mom-scratchpad-")); + + try { + const recentMessages = getRecentMessages(channelDir, 50); + const systemPrompt = buildSystemPrompt(channelDir, scratchpadDir, recentMessages); + + // Set up file upload function for the attach tool + setUploadFunction(async (filePath: string, title?: string) => { + await ctx.uploadFile(filePath, title); + }); + + // Create ephemeral agent + agent = new Agent({ + initialState: { + systemPrompt, + model, + thinkingLevel: "off", + tools: momTools, + }, + transport: new ProviderTransport({ + getApiKey: async () => getAnthropicApiKey(), + }), + }); + + // Subscribe to events + agent.subscribe(async (event: AgentEvent) => { + switch (event.type) { + case "tool_execution_start": { + const args = event.args as { label?: string }; + const label = args.label || event.toolName; + + // Log to console + console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`); + + // Log to jsonl + await store.logMessage(ctx.message.channel, { + ts: Date.now().toString(), + user: "bot", + text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`, + attachments: [], + isBot: true, + }); + + // Show only label to user (italic) + await ctx.respond(`_${label}_`); + break; + } + + case "tool_execution_end": { + const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result); + + // Log to console + console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`); + + // Log to jsonl + await store.logMessage(ctx.message.channel, { + ts: Date.now().toString(), + user: "bot", + text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`, + attachments: [], + isBot: true, + }); + + // Show brief status to user (only on error) + if (event.isError) { + await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`); + } + break; + } + + case "message_update": { + const ev = event.assistantMessageEvent; + // Stream deltas to console + if (ev.type === "text_delta") { + process.stdout.write(ev.delta); + } else if (ev.type === "thinking_delta") { + process.stdout.write(ev.delta); + } + break; + } + + case "message_start": + if (event.message.role === "assistant") { + process.stdout.write("\n"); + } + break; + + case "message_end": + if (event.message.role === "assistant") { + process.stdout.write("\n"); + // Extract text from assistant message + const content = event.message.content; + let text = ""; + for (const part of content) { + if (part.type === "text") { + text += part.text; + } + } + if (text.trim()) { + await ctx.respond(text); + } + } + break; + } + }); + + // Run the agent with user's message + await agent.prompt(ctx.message.text || "(attached files)"); + } finally { + agent = null; + // Cleanup scratchpad + try { + rmSync(scratchpadDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + }, + + abort(): void { + agent?.abort(); + }, + }; +} diff --git a/packages/mom/src/main.ts b/packages/mom/src/main.ts new file mode 100644 index 00000000..b55526b1 --- /dev/null +++ b/packages/mom/src/main.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import { join, resolve } from "path"; +import { type AgentRunner, createAgentRunner } from "./agent.js"; +import { MomBot, type SlackContext } from "./slack.js"; + +console.log("Starting mom bot..."); + +const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN; +const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN; +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN; + +// Parse command line arguments +const args = process.argv.slice(2); +if (args.length !== 1) { + console.error("Usage: mom "); + console.error("Example: mom ./mom-data"); + process.exit(1); +} + +const workingDir = resolve(args[0]); + +if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) { + console.error("Missing required environment variables:"); + if (!MOM_SLACK_APP_TOKEN) console.error(" - MOM_SLACK_APP_TOKEN (xapp-...)"); + if (!MOM_SLACK_BOT_TOKEN) console.error(" - MOM_SLACK_BOT_TOKEN (xoxb-...)"); + if (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN) console.error(" - ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN"); + process.exit(1); +} + +// Track active agent runs per channel +const activeRuns = new Map(); + +async function handleMessage(ctx: SlackContext, source: "channel" | "dm"): Promise { + const channelId = ctx.message.channel; + const messageText = ctx.message.text.toLowerCase().trim(); + + // Check for stop command + if (messageText === "stop") { + const runner = activeRuns.get(channelId); + if (runner) { + console.log(`Stop requested for channel ${channelId}`); + runner.abort(); + await ctx.respond("_Stopping..._"); + } else { + await ctx.respond("_Nothing running._"); + } + return; + } + + // Check if already running in this channel + if (activeRuns.has(channelId)) { + await ctx.respond("_Already working on something. Say `@mom stop` to cancel._"); + return; + } + + console.log(`${source === "channel" ? "Channel mention" : "DM"} from <@${ctx.message.user}>: ${ctx.message.text}`); + const channelDir = join(workingDir, channelId); + + const runner = createAgentRunner(); + activeRuns.set(channelId, runner); + + await ctx.setTyping(true); + try { + await runner.run(ctx, channelDir, ctx.store); + } catch (error) { + // Don't report abort errors + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("aborted") || msg.includes("Aborted")) { + // Already said "Stopping..." - nothing more to say + } else { + console.error("Agent error:", error); + await ctx.respond(`❌ Error: ${msg}`); + } + } finally { + activeRuns.delete(channelId); + } +} + +const bot = new MomBot( + { + async onChannelMention(ctx) { + await handleMessage(ctx, "channel"); + }, + + async onDirectMessage(ctx) { + await handleMessage(ctx, "dm"); + }, + }, + { + appToken: MOM_SLACK_APP_TOKEN, + botToken: MOM_SLACK_BOT_TOKEN, + workingDir, + }, +); + +bot.start(); diff --git a/packages/mom/src/slack.ts b/packages/mom/src/slack.ts new file mode 100644 index 00000000..620162bf --- /dev/null +++ b/packages/mom/src/slack.ts @@ -0,0 +1,259 @@ +import { SocketModeClient } from "@slack/socket-mode"; +import { WebClient } from "@slack/web-api"; +import { readFileSync } from "fs"; +import { basename } from "path"; +import { type Attachment, ChannelStore } from "./store.js"; + +export interface SlackMessage { + text: string; // message content (mentions stripped) + rawText: string; // original text with mentions + user: string; // user ID + channel: string; // channel ID + ts: string; // timestamp (for threading) + attachments: Attachment[]; // file attachments +} + +export interface SlackContext { + message: SlackMessage; + store: ChannelStore; + /** Send a new message */ + respond(text: string): Promise; + /** Show/hide typing indicator. If text is provided to respond() after setTyping(true), it updates the typing message instead of posting new. */ + setTyping(isTyping: boolean): Promise; + /** Upload a file to the channel */ + uploadFile(filePath: string, title?: string): Promise; +} + +export interface MomHandler { + onChannelMention(ctx: SlackContext): Promise; + onDirectMessage(ctx: SlackContext): Promise; +} + +export interface MomBotConfig { + appToken: string; + botToken: string; + workingDir: string; // directory for channel data and attachments +} + +export class MomBot { + private socketClient: SocketModeClient; + private webClient: WebClient; + private handler: MomHandler; + private botUserId: string | null = null; + public readonly store: ChannelStore; + private userCache: Map = new Map(); + + constructor(handler: MomHandler, config: MomBotConfig) { + this.handler = handler; + this.socketClient = new SocketModeClient({ appToken: config.appToken }); + this.webClient = new WebClient(config.botToken); + this.store = new ChannelStore({ + workingDir: config.workingDir, + botToken: config.botToken, + }); + + this.setupEventHandlers(); + } + + private async getUserInfo(userId: string): Promise<{ userName: string; displayName: string }> { + if (this.userCache.has(userId)) { + return this.userCache.get(userId)!; + } + + try { + const result = await this.webClient.users.info({ user: userId }); + const user = result.user as { name?: string; real_name?: string }; + const info = { + userName: user?.name || userId, + displayName: user?.real_name || user?.name || userId, + }; + this.userCache.set(userId, info); + return info; + } catch { + return { userName: userId, displayName: userId }; + } + } + + private setupEventHandlers(): void { + // Handle @mentions in channels + this.socketClient.on("app_mention", async ({ event, ack }) => { + await ack(); + + const slackEvent = event as { + text: string; + channel: string; + user: string; + ts: string; + files?: Array<{ name: string; url_private_download?: string; url_private?: string }>; + }; + + // Log the mention (message event may not fire for app_mention) + await this.logMessage(slackEvent); + + const ctx = this.createContext(slackEvent); + await this.handler.onChannelMention(ctx); + }); + + // Handle all messages (for logging) and DMs (for triggering handler) + this.socketClient.on("message", async ({ event, ack }) => { + await ack(); + + const slackEvent = event as { + text?: string; + channel: string; + user?: string; + ts: string; + channel_type?: string; + subtype?: string; + bot_id?: string; + files?: Array<{ name: string; url_private_download?: string; url_private?: string }>; + }; + + // Ignore bot messages + if (slackEvent.bot_id) return; + // Ignore message edits, etc. (but allow file_share) + if (slackEvent.subtype !== undefined && slackEvent.subtype !== "file_share") return; + // Ignore if no user + if (!slackEvent.user) return; + // Ignore messages from the bot itself + if (slackEvent.user === this.botUserId) return; + // Ignore if no text AND no files + if (!slackEvent.text && (!slackEvent.files || slackEvent.files.length === 0)) return; + + // Log ALL messages (channel and DM) + await this.logMessage({ + text: slackEvent.text || "", + channel: slackEvent.channel, + user: slackEvent.user, + ts: slackEvent.ts, + files: slackEvent.files, + }); + + // Only trigger handler for DMs (channel mentions are handled by app_mention event) + if (slackEvent.channel_type === "im") { + const ctx = this.createContext({ + text: slackEvent.text || "", + channel: slackEvent.channel, + user: slackEvent.user, + ts: slackEvent.ts, + files: slackEvent.files, + }); + await this.handler.onDirectMessage(ctx); + } + }); + } + + private async logMessage(event: { + text: string; + channel: string; + user: string; + ts: string; + files?: Array<{ name: string; url_private_download?: string; url_private?: string }>; + }): Promise { + const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : []; + const { userName, displayName } = await this.getUserInfo(event.user); + + await this.store.logMessage(event.channel, { + ts: event.ts, + user: event.user, + userName, + displayName, + text: event.text, + attachments, + isBot: false, + }); + } + + private createContext(event: { + text: string; + channel: string; + user: string; + ts: string; + files?: Array<{ name: string; url_private_download?: string; url_private?: string }>; + }): SlackContext { + const rawText = event.text; + const text = rawText.replace(/<@[A-Z0-9]+>/gi, "").trim(); + + // Process attachments (for context, already logged by message handler) + const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : []; + + let typingMessageTs: string | null = null; + + return { + message: { + text, + rawText, + user: event.user, + channel: event.channel, + ts: event.ts, + attachments, + }, + store: this.store, + respond: async (responseText: string) => { + let responseTs: string; + + if (typingMessageTs) { + // Update the typing message with the response + await this.webClient.chat.update({ + channel: event.channel, + ts: typingMessageTs, + text: responseText, + }); + responseTs = typingMessageTs; + typingMessageTs = null; + } else { + // Post a new message + const result = await this.webClient.chat.postMessage({ + channel: event.channel, + text: responseText, + }); + responseTs = result.ts as string; + } + + // Log the bot response + await this.store.logBotResponse(event.channel, responseText, responseTs); + }, + setTyping: async (isTyping: boolean) => { + if (isTyping && !typingMessageTs) { + // Post a "thinking" message (italic) + const result = await this.webClient.chat.postMessage({ + channel: event.channel, + text: "_Thinking..._", + }); + typingMessageTs = result.ts as string; + } else if (!isTyping && typingMessageTs) { + // Clear typing state (message will be updated by respond()) + // If respond() wasn't called, delete the typing message + await this.webClient.chat.delete({ + channel: event.channel, + ts: typingMessageTs, + }); + typingMessageTs = null; + } + }, + uploadFile: async (filePath: string, title?: string) => { + const fileName = title || basename(filePath); + const fileContent = readFileSync(filePath); + + await this.webClient.files.uploadV2({ + channel_id: event.channel, + file: fileContent, + filename: fileName, + title: fileName, + }); + }, + }; + } + + async start(): Promise { + const auth = await this.webClient.auth.test(); + this.botUserId = auth.user_id as string; + await this.socketClient.start(); + console.log("⚡️ Mom bot connected and listening!"); + } + + async stop(): Promise { + await this.socketClient.disconnect(); + console.log("Mom bot disconnected."); + } +} diff --git a/packages/mom/src/store.ts b/packages/mom/src/store.ts new file mode 100644 index 00000000..224968c3 --- /dev/null +++ b/packages/mom/src/store.ts @@ -0,0 +1,173 @@ +import { existsSync, mkdirSync } from "fs"; +import { appendFile, writeFile } from "fs/promises"; +import { join } from "path"; + +export interface Attachment { + original: string; // original filename from uploader + local: string; // path relative to working dir (e.g., "C12345/attachments/1732531234567_file.png") +} + +export interface LoggedMessage { + ts: string; // slack timestamp + user: string; // user ID (or "bot" for bot responses) + userName?: string; // handle (e.g., "mario") + displayName?: string; // display name (e.g., "Mario Zechner") + text: string; + attachments: Attachment[]; + isBot: boolean; +} + +export interface ChannelStoreConfig { + workingDir: string; + botToken: string; // needed for authenticated file downloads +} + +interface PendingDownload { + channelId: string; + localPath: string; // relative path + url: string; +} + +export class ChannelStore { + private workingDir: string; + private botToken: string; + private pendingDownloads: PendingDownload[] = []; + private isDownloading = false; + + constructor(config: ChannelStoreConfig) { + this.workingDir = config.workingDir; + this.botToken = config.botToken; + + // Ensure working directory exists + if (!existsSync(this.workingDir)) { + mkdirSync(this.workingDir, { recursive: true }); + } + } + + /** + * Get or create the directory for a channel/DM + */ + getChannelDir(channelId: string): string { + const dir = join(this.workingDir, channelId); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; + } + + /** + * Generate a unique local filename for an attachment + */ + generateLocalFilename(originalName: string, timestamp: string): string { + // Convert slack timestamp (1234567890.123456) to milliseconds + const ts = Math.floor(parseFloat(timestamp) * 1000); + // Sanitize original name (remove problematic characters) + const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, "_"); + return `${ts}_${sanitized}`; + } + + /** + * Process attachments from a Slack message event + * Returns attachment metadata and queues downloads + */ + processAttachments( + channelId: string, + files: Array<{ name: string; url_private_download?: string; url_private?: string }>, + timestamp: string, + ): Attachment[] { + const attachments: Attachment[] = []; + + for (const file of files) { + const url = file.url_private_download || file.url_private; + if (!url) continue; + + const filename = this.generateLocalFilename(file.name, timestamp); + const localPath = `${channelId}/attachments/${filename}`; + + attachments.push({ + original: file.name, + local: localPath, + }); + + // Queue for background download + this.pendingDownloads.push({ channelId, localPath, url }); + } + + // Trigger background download + this.processDownloadQueue(); + + return attachments; + } + + /** + * Log a message to the channel's log.jsonl + */ + async logMessage(channelId: string, message: LoggedMessage): Promise { + const logPath = join(this.getChannelDir(channelId), "log.jsonl"); + const line = JSON.stringify(message) + "\n"; + await appendFile(logPath, line, "utf-8"); + } + + /** + * Log a bot response + */ + async logBotResponse(channelId: string, text: string, ts: string): Promise { + await this.logMessage(channelId, { + ts, + user: "bot", + text, + attachments: [], + isBot: true, + }); + } + + /** + * Process the download queue in the background + */ + private async processDownloadQueue(): Promise { + if (this.isDownloading || this.pendingDownloads.length === 0) return; + + this.isDownloading = true; + + while (this.pendingDownloads.length > 0) { + const item = this.pendingDownloads.shift(); + if (!item) break; + + try { + await this.downloadAttachment(item.localPath, item.url); + console.log(`Downloaded: ${item.localPath}`); + } catch (error) { + console.error(`Failed to download ${item.localPath}:`, error); + // Could re-queue for retry here + } + } + + this.isDownloading = false; + } + + /** + * Download a single attachment + */ + private async downloadAttachment(localPath: string, url: string): Promise { + const filePath = join(this.workingDir, localPath); + + // Ensure directory exists + const dir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf("/"))); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${this.botToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + await writeFile(filePath, Buffer.from(buffer)); + } +} diff --git a/packages/mom/src/tools/attach.ts b/packages/mom/src/tools/attach.ts new file mode 100644 index 00000000..073fc9c5 --- /dev/null +++ b/packages/mom/src/tools/attach.ts @@ -0,0 +1,46 @@ +import type { AgentTool } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { basename, resolve as resolvePath } from "path"; + +// This will be set by the agent before running +let uploadFn: ((filePath: string, title?: string) => Promise) | null = null; + +export function setUploadFunction(fn: (filePath: string, title?: string) => Promise): void { + uploadFn = fn; +} + +const attachSchema = Type.Object({ + label: Type.String({ description: "Brief description of what you're sharing (shown to user)" }), + path: Type.String({ description: "Path to the file to attach" }), + title: Type.Optional(Type.String({ description: "Title for the file (defaults to filename)" })), +}); + +export const attachTool: AgentTool = { + name: "attach", + label: "attach", + description: "Attach a file to your response. Use this to share files, images, or documents with the user.", + parameters: attachSchema, + execute: async ( + _toolCallId: string, + { path, title }: { label: string; path: string; title?: string }, + signal?: AbortSignal, + ) => { + if (!uploadFn) { + throw new Error("Upload function not configured"); + } + + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + const absolutePath = resolvePath(path); + const fileName = title || basename(absolutePath); + + await uploadFn(absolutePath, fileName); + + return { + content: [{ type: "text" as const, text: `Attached file: ${fileName}` }], + details: undefined, + }; + }, +}; diff --git a/packages/mom/src/tools/bash.ts b/packages/mom/src/tools/bash.ts new file mode 100644 index 00000000..907b0295 --- /dev/null +++ b/packages/mom/src/tools/bash.ts @@ -0,0 +1,189 @@ +import type { AgentTool } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { spawn } from "child_process"; +import { existsSync } from "fs"; + +/** + * Get shell configuration based on platform + */ +function getShellConfig(): { shell: string; args: string[] } { + if (process.platform === "win32") { + const paths: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) { + paths.push(`${programFiles}\\Git\\bin\\bash.exe`); + } + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + } + + for (const path of paths) { + if (existsSync(path)) { + return { shell: path, args: ["-c"] }; + } + } + + throw new Error( + `Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` + + `Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`, + ); + } + return { shell: "sh", args: ["-c"] }; +} + +/** + * Kill a process and all its children + */ +function killProcessTree(pid: number): void { + if (process.platform === "win32") { + // Use taskkill on Windows to kill process tree + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + }); + } catch { + // Ignore errors if taskkill fails + } + } else { + // Use SIGKILL on Unix/Linux/Mac + try { + process.kill(-pid, "SIGKILL"); + } catch { + // Fallback to killing just the child if process group kill fails + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead + } + } + } +} + +const bashSchema = Type.Object({ + label: Type.String({ description: "Brief description of what this command does (shown to user)" }), + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), +}); + +export const bashTool: AgentTool = { + name: "bash", + label: "bash", + description: + "Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.", + parameters: bashSchema, + execute: async ( + _toolCallId: string, + { command, timeout }: { label: string; command: string; timeout?: number }, + signal?: AbortSignal, + ) => { + return new Promise((resolve, reject) => { + const { shell, args } = getShellConfig(); + const child = spawn(shell, [...args, command], { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + + // Set timeout if provided + let timeoutHandle: NodeJS.Timeout | undefined; + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + onAbort(); + }, timeout * 1000); + } + + // Collect stdout + if (child.stdout) { + child.stdout.on("data", (data) => { + stdout += data.toString(); + // Limit buffer size + if (stdout.length > 10 * 1024 * 1024) { + stdout = stdout.slice(0, 10 * 1024 * 1024); + } + }); + } + + // Collect stderr + if (child.stderr) { + child.stderr.on("data", (data) => { + stderr += data.toString(); + // Limit buffer size + if (stderr.length > 10 * 1024 * 1024) { + stderr = stderr.slice(0, 10 * 1024 * 1024); + } + }); + } + + // Handle process exit + child.on("close", (code) => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (signal?.aborted) { + let output = ""; + if (stdout) output += stdout; + if (stderr) { + if (output) output += "\n"; + output += stderr; + } + if (output) output += "\n\n"; + output += "Command aborted"; + reject(new Error(output)); + return; + } + + if (timedOut) { + let output = ""; + if (stdout) output += stdout; + if (stderr) { + if (output) output += "\n"; + output += stderr; + } + if (output) output += "\n\n"; + output += `Command timed out after ${timeout} seconds`; + reject(new Error(output)); + return; + } + + let output = ""; + if (stdout) output += stdout; + if (stderr) { + if (output) output += "\n"; + output += stderr; + } + + if (code !== 0 && code !== null) { + if (output) output += "\n\n"; + reject(new Error(`${output}Command exited with code ${code}`)); + } else { + resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined }); + } + }); + + // Handle abort signal - kill entire process tree + const onAbort = () => { + if (child.pid) { + killProcessTree(child.pid); + } + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + }); + }, +}; diff --git a/packages/mom/src/tools/edit.ts b/packages/mom/src/tools/edit.ts new file mode 100644 index 00000000..52e147a7 --- /dev/null +++ b/packages/mom/src/tools/edit.ts @@ -0,0 +1,269 @@ +import * as os from "node:os"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import * as Diff from "diff"; +import { constants } from "fs"; +import { access, readFile, writeFile } from "fs/promises"; +import { resolve as resolvePath } from "path"; + +/** + * Expand ~ to home directory + */ +function expandPath(filePath: string): string { + if (filePath === "~") { + return os.homedir(); + } + if (filePath.startsWith("~/")) { + return os.homedir() + filePath.slice(1); + } + return filePath; +} + +/** + * Generate a unified diff string with line numbers and context + */ +function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string { + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; + + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } + + if (part.added || part.removed) { + // Show the change + for (const line of raw) { + if (part.added) { + const lineNum = String(newLineNum).padStart(lineNumWidth, " "); + output.push(`+${lineNum} ${line}`); + newLineNum++; + } else { + // removed + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(`-${lineNum} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + // Context lines - only show a few before/after changes + const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + + if (lastWasChange || nextPartIsChange) { + // Show context + let linesToShow = raw; + let skipStart = 0; + let skipEnd = 0; + + if (!lastWasChange) { + // Show only last N lines as leading context + skipStart = Math.max(0, raw.length - contextLines); + linesToShow = raw.slice(skipStart); + } + + if (!nextPartIsChange && linesToShow.length > contextLines) { + // Show only first N lines as trailing context + skipEnd = linesToShow.length - contextLines; + linesToShow = linesToShow.slice(0, contextLines); + } + + // Add ellipsis if we skipped lines at start + if (skipStart > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + } + + for (const line of linesToShow) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + + // Add ellipsis if we skipped lines at end + if (skipEnd > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + } + + // Update line numbers for skipped lines + oldLineNum += skipStart + skipEnd; + newLineNum += skipStart + skipEnd; + } else { + // Skip these context lines entirely + oldLineNum += raw.length; + newLineNum += raw.length; + } + + lastWasChange = false; + } + } + + return output.join("\n"); +} + +const editSchema = Type.Object({ + label: Type.String({ description: "Brief description of the edit you're making (shown to user)" }), + path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), + oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }), + newText: Type.String({ description: "New text to replace the old text with" }), +}); + +export const editTool: AgentTool = { + name: "edit", + label: "edit", + description: + "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.", + parameters: editSchema, + execute: async ( + _toolCallId: string, + { path, oldText, newText }: { label: string; path: string; oldText: string; newText: string }, + signal?: AbortSignal, + ) => { + const absolutePath = resolvePath(expandPath(path)); + + return new Promise<{ + content: Array<{ type: "text"; text: string }>; + details: { diff: string } | undefined; + }>((resolve, reject) => { + // Check if already aborted + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + let aborted = false; + + // Set up abort handler + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + // Perform the edit operation + (async () => { + try { + // Check if file exists + try { + await access(absolutePath, constants.R_OK | constants.W_OK); + } catch { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject(new Error(`File not found: ${path}`)); + return; + } + + // Check if aborted before reading + if (aborted) { + return; + } + + // Read the file + const content = await readFile(absolutePath, "utf-8"); + + // Check if aborted after reading + if (aborted) { + return; + } + + // Check if old text exists + if (!content.includes(oldText)) { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject( + new Error( + `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, + ), + ); + return; + } + + // Count occurrences + const occurrences = content.split(oldText).length - 1; + + if (occurrences > 1) { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject( + new Error( + `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, + ), + ); + return; + } + + // Check if aborted before writing + if (aborted) { + return; + } + + // Perform replacement using indexOf + substring (raw string replace, no special character interpretation) + // String.replace() interprets $ in the replacement string, so we do manual replacement + const index = content.indexOf(oldText); + const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length); + + // Verify the replacement actually changed something + if (content === newContent) { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + reject( + new Error( + `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, + ), + ); + return; + } + + await writeFile(absolutePath, newContent, "utf-8"); + + // Check if aborted after writing + if (aborted) { + return; + } + + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + resolve({ + content: [ + { + type: "text", + text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`, + }, + ], + details: { diff: generateDiffString(content, newContent) }, + }); + } catch (error: unknown) { + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (!aborted) { + reject(error); + } + } + })(); + }); + }, +}; diff --git a/packages/mom/src/tools/index.ts b/packages/mom/src/tools/index.ts new file mode 100644 index 00000000..d310d28e --- /dev/null +++ b/packages/mom/src/tools/index.ts @@ -0,0 +1,13 @@ +export { attachTool, setUploadFunction } from "./attach.js"; +export { bashTool } from "./bash.js"; +export { editTool } from "./edit.js"; +export { readTool } from "./read.js"; +export { writeTool } from "./write.js"; + +import { attachTool } from "./attach.js"; +import { bashTool } from "./bash.js"; +import { editTool } from "./edit.js"; +import { readTool } from "./read.js"; +import { writeTool } from "./write.js"; + +export const momTools = [readTool, bashTool, editTool, writeTool, attachTool]; diff --git a/packages/mom/src/tools/read.ts b/packages/mom/src/tools/read.ts new file mode 100644 index 00000000..1cfc99c6 --- /dev/null +++ b/packages/mom/src/tools/read.ts @@ -0,0 +1,179 @@ +import * as os from "node:os"; +import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { constants } from "fs"; +import { access, readFile } from "fs/promises"; +import { extname, resolve as resolvePath } from "path"; + +/** + * Expand ~ to home directory + */ +function expandPath(filePath: string): string { + if (filePath === "~") { + return os.homedir(); + } + if (filePath.startsWith("~/")) { + return os.homedir() + filePath.slice(1); + } + return filePath; +} + +/** + * Map of file extensions to MIME types for common image formats + */ +const IMAGE_MIME_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", +}; + +/** + * Check if a file is an image based on its extension + */ +function isImageFile(filePath: string): string | null { + const ext = extname(filePath).toLowerCase(); + return IMAGE_MIME_TYPES[ext] || null; +} + +const readSchema = Type.Object({ + label: Type.String({ description: "Brief description of what you're reading and why (shown to user)" }), + path: Type.String({ description: "Path to the file to read (relative or absolute)" }), + offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), +}); + +const MAX_LINES = 2000; +const MAX_LINE_LENGTH = 2000; + +export const readTool: AgentTool = { + name: "read", + label: "read", + description: + "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.", + parameters: readSchema, + execute: async ( + _toolCallId: string, + { path, offset, limit }: { label: string; path: string; offset?: number; limit?: number }, + signal?: AbortSignal, + ) => { + const absolutePath = resolvePath(expandPath(path)); + const mimeType = isImageFile(absolutePath); + + return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => { + // Check if already aborted + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + let aborted = false; + + // Set up abort handler + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + // Perform the read operation + (async () => { + try { + // Check if file exists + await access(absolutePath, constants.R_OK); + + // Check if aborted before reading + if (aborted) { + return; + } + + // Read the file based on type + let content: (TextContent | ImageContent)[]; + + if (mimeType) { + // Read as image (binary) + const buffer = await readFile(absolutePath); + const base64 = buffer.toString("base64"); + + content = [ + { type: "text", text: `Read image file [${mimeType}]` }, + { type: "image", data: base64, mimeType }, + ]; + } else { + // Read as text + const textContent = await readFile(absolutePath, "utf-8"); + const lines = textContent.split("\n"); + + // Apply offset and limit (matching Claude Code Read tool behavior) + const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed + const maxLines = limit || MAX_LINES; + const endLine = Math.min(startLine + maxLines, lines.length); + + // Check if offset is out of bounds + if (startLine >= lines.length) { + throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`); + } + + // Get the relevant lines + const selectedLines = lines.slice(startLine, endLine); + + // Truncate long lines and track which were truncated + let hadTruncatedLines = false; + const formattedLines = selectedLines.map((line) => { + if (line.length > MAX_LINE_LENGTH) { + hadTruncatedLines = true; + return line.slice(0, MAX_LINE_LENGTH); + } + return line; + }); + + let outputText = formattedLines.join("\n"); + + // Add notices + const notices: string[] = []; + + if (hadTruncatedLines) { + notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`); + } + + if (endLine < lines.length) { + const remaining = lines.length - endLine; + notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`); + } + + if (notices.length > 0) { + outputText += `\n\n... (${notices.join(". ")})`; + } + + content = [{ type: "text", text: outputText }]; + } + + // Check if aborted after reading + if (aborted) { + return; + } + + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + resolve({ content, details: undefined }); + } catch (error: unknown) { + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (!aborted) { + reject(error); + } + } + })(); + }); + }, +}; diff --git a/packages/mom/src/tools/write.ts b/packages/mom/src/tools/write.ts new file mode 100644 index 00000000..82aa63dd --- /dev/null +++ b/packages/mom/src/tools/write.ts @@ -0,0 +1,100 @@ +import * as os from "node:os"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { mkdir, writeFile } from "fs/promises"; +import { dirname, resolve as resolvePath } from "path"; + +/** + * Expand ~ to home directory + */ +function expandPath(filePath: string): string { + if (filePath === "~") { + return os.homedir(); + } + if (filePath.startsWith("~/")) { + return os.homedir() + filePath.slice(1); + } + return filePath; +} + +const writeSchema = Type.Object({ + label: Type.String({ description: "Brief description of what you're writing (shown to user)" }), + path: Type.String({ description: "Path to the file to write (relative or absolute)" }), + content: Type.String({ description: "Content to write to the file" }), +}); + +export const writeTool: AgentTool = { + name: "write", + label: "write", + description: + "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", + parameters: writeSchema, + execute: async ( + _toolCallId: string, + { path, content }: { label: string; path: string; content: string }, + signal?: AbortSignal, + ) => { + const absolutePath = resolvePath(expandPath(path)); + const dir = dirname(absolutePath); + + return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>((resolve, reject) => { + // Check if already aborted + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + let aborted = false; + + // Set up abort handler + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + // Perform the write operation + (async () => { + try { + // Create parent directories if needed + await mkdir(dir, { recursive: true }); + + // Check if aborted before writing + if (aborted) { + return; + } + + // Write the file + await writeFile(absolutePath, content, "utf-8"); + + // Check if aborted after writing + if (aborted) { + return; + } + + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + resolve({ + content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], + details: undefined, + }); + } catch (error: unknown) { + // Clean up abort handler + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (!aborted) { + reject(error); + } + } + })(); + }); + }, +}; diff --git a/packages/mom/tsconfig.build.json b/packages/mom/tsconfig.build.json new file mode 100644 index 00000000..695dd9ad --- /dev/null +++ b/packages/mom/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index d807b6dc..2c833214 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "@mariozechner/pi-agent": ["./packages/agent/src/index.ts"], "@mariozechner/pi-agent-old": ["./packages/agent-old/src/index.ts"], "@mariozechner/coding-agent": ["./packages/coding-agent/src/index.ts"], - "@mariozechner/pi": ["./packages/pods/src/index.ts"] + "@mariozechner/pi": ["./packages/pods/src/index.ts"], + "@mariozechner/pi-mom": ["./packages/mom/src/main.ts"] } }, "include": ["packages/*/src/**/*", "packages/*/test/**/*"]