mom: Slack bot with abort support, streaming console output, removed sandbox

This commit is contained in:
Mario Zechner 2025-11-26 00:27:21 +01:00
parent a7423b954e
commit aa9e058249
22 changed files with 2741 additions and 58 deletions

View file

@ -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.

View file

@ -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 |

553
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -0,0 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Initial scaffold

3
packages/mom/README.md Normal file
View file

@ -0,0 +1,3 @@
# @mariozechner/pi-mom
Slack bot that delegates channel messages to a pi coding agent instance.

View file

@ -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)

View file

@ -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.

52
packages/mom/package.json Normal file
View file

@ -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"
}
}

242
packages/mom/src/agent.ts Normal file
View file

@ -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<void>;
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: <url|text>
- 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<void> {
// 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();
},
};
}

98
packages/mom/src/main.ts Normal file
View file

@ -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 <working-directory>");
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<string, AgentRunner>();
async function handleMessage(ctx: SlackContext, source: "channel" | "dm"): Promise<void> {
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();

259
packages/mom/src/slack.ts Normal file
View file

@ -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<void>;
/** 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<void>;
/** Upload a file to the channel */
uploadFile(filePath: string, title?: string): Promise<void>;
}
export interface MomHandler {
onChannelMention(ctx: SlackContext): Promise<void>;
onDirectMessage(ctx: SlackContext): Promise<void>;
}
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<string, { userName: string; displayName: string }> = 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<void> {
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<void> {
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<void> {
await this.socketClient.disconnect();
console.log("Mom bot disconnected.");
}
}

173
packages/mom/src/store.ts Normal file
View file

@ -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<void> {
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<void> {
await this.logMessage(channelId, {
ts,
user: "bot",
text,
attachments: [],
isBot: true,
});
}
/**
* Process the download queue in the background
*/
private async processDownloadQueue(): Promise<void> {
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<void> {
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));
}
}

View file

@ -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<void>) | null = null;
export function setUploadFunction(fn: (filePath: string, title?: string) => Promise<void>): 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<typeof attachSchema> = {
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,
};
},
};

View file

@ -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<typeof bashSchema> = {
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 });
}
}
});
},
};

View file

@ -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<typeof editSchema> = {
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);
}
}
})();
});
},
};

View file

@ -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];

View file

@ -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<string, string> = {
".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<typeof readSchema> = {
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);
}
}
})();
});
},
};

View file

@ -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<typeof writeSchema> = {
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);
}
}
})();
});
},
};

View file

@ -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"]
}

View file

@ -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/**/*"]