mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 08:02:17 +00:00
mom: Slack bot with abort support, streaming console output, removed sandbox
This commit is contained in:
parent
a7423b954e
commit
aa9e058249
22 changed files with 2741 additions and 58 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
553
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
7
packages/mom/CHANGELOG.md
Normal file
7
packages/mom/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Initial scaffold
|
||||
3
packages/mom/README.md
Normal file
3
packages/mom/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# @mariozechner/pi-mom
|
||||
|
||||
Slack bot that delegates channel messages to a pi coding agent instance.
|
||||
95
packages/mom/docs/sandbox.md
Normal file
95
packages/mom/docs/sandbox.md
Normal 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)
|
||||
399
packages/mom/docs/slack-bot-minimal-guide.md
Normal file
399
packages/mom/docs/slack-bot-minimal-guide.md
Normal 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
52
packages/mom/package.json
Normal 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
242
packages/mom/src/agent.ts
Normal 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
98
packages/mom/src/main.ts
Normal 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
259
packages/mom/src/slack.ts
Normal 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
173
packages/mom/src/store.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
46
packages/mom/src/tools/attach.ts
Normal file
46
packages/mom/src/tools/attach.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
189
packages/mom/src/tools/bash.ts
Normal file
189
packages/mom/src/tools/bash.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
269
packages/mom/src/tools/edit.ts
Normal file
269
packages/mom/src/tools/edit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
13
packages/mom/src/tools/index.ts
Normal file
13
packages/mom/src/tools/index.ts
Normal 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];
|
||||
179
packages/mom/src/tools/read.ts
Normal file
179
packages/mom/src/tools/read.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
100
packages/mom/src/tools/write.ts
Normal file
100
packages/mom/src/tools/write.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
},
|
||||
};
|
||||
9
packages/mom/tsconfig.build.json
Normal file
9
packages/mom/tsconfig.build.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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/**/*"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue