pi-mom-slack 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +222 -2
- package/dist/agent-env.d.ts +1 -0
- package/dist/agent-env.js +16 -0
- package/dist/agent.d.ts +59 -0
- package/dist/agent.js +142 -0
- package/dist/attachments.d.ts +11 -0
- package/dist/attachments.js +34 -0
- package/dist/bot.d.ts +58 -0
- package/dist/bot.js +259 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +60 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +56 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/lifecycle.d.ts +25 -0
- package/dist/lifecycle.js +79 -0
- package/dist/models.d.ts +20 -0
- package/dist/models.js +48 -0
- package/dist/run-markers.d.ts +19 -0
- package/dist/run-markers.js +69 -0
- package/dist/run-processes.d.ts +7 -0
- package/dist/run-processes.js +52 -0
- package/dist/slack.d.ts +77 -0
- package/dist/slack.js +278 -0
- package/dist/upload-tool.d.ts +8 -0
- package/dist/upload-tool.js +46 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +8 -0
- package/manifest.json +43 -0
- package/package.json +48 -4
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Suau
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,223 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-mom-slack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run the [pi coding agent](https://github.com/earendil-works/pi-mono) in Slack. Mention it in a channel or write to it directly: each Slack thread gets its own pi session and its own working directory, kept across restarts.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx pi-mom-slack
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
<p align="center"><img src="docs/screenshots/hero.jpg" alt="A request in #demo: 👀, then the thread with the status line, the uploaded primes.txt, the report, and ✅" width="85%"></p>
|
|
10
|
+
|
|
11
|
+
Inspired by `mom`, the Slack bot removed from pi-mono on 30 April 2026. Not affiliated with Earendil.
|
|
12
|
+
|
|
13
|
+
## What you get
|
|
14
|
+
|
|
15
|
+
- **One pi session per thread.** Follow-ups in the thread keep the context, even after the bot restarts.
|
|
16
|
+
- **Mentions, direct messages, and plain replies** in a thread the bot already answers.
|
|
17
|
+
- **Files both ways.** Attachments land in the thread's working directory (images go to the model when it reads them); the agent sends files back with its `slack_upload` tool.
|
|
18
|
+
- **Visible progress.** 👀 on your message, a status line updated as tools run, then ✅ or ⚠️.
|
|
19
|
+
- **Commands:** `!stop`, `!status`, `!model`, `!help`.
|
|
20
|
+
- **Robust by default.** A run cut by a restart resumes on its own (at most twice), a Slack connection that stays down makes the process exit for its supervisor to restart it, the startup message says why the bot restarted, the agent's shell never sees the Slack tokens or other credentials, and a second process on the same app is detected.
|
|
21
|
+
- **Your pi, as configured.** Model, credentials and skills come from `~/.pi/agent/`, like `pi` in your terminal.
|
|
22
|
+
|
|
23
|
+
## Setup (5 minutes)
|
|
24
|
+
|
|
25
|
+
You need Node.js 22.19 or later, and pi set up on the machine that runs the bot: run `npx @earendil-works/pi-coding-agent` once and log in with `/login` (or set your provider's API key).
|
|
26
|
+
|
|
27
|
+
### 1. Create the Slack app
|
|
28
|
+
|
|
29
|
+
**[Create the app from the manifest](https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%3A%22pi%22%2C%22description%22%3A%22pi%20coding%20agent%20in%20Slack%22%7D%2C%22features%22%3A%7B%22app_home%22%3A%7B%22home_tab_enabled%22%3Afalse%2C%22messages_tab_enabled%22%3Atrue%2C%22messages_tab_read_only_enabled%22%3Afalse%7D%2C%22bot_user%22%3A%7B%22display_name%22%3A%22pi%22%2C%22always_online%22%3Atrue%7D%7D%2C%22oauth_config%22%3A%7B%22scopes%22%3A%7B%22bot%22%3A%5B%22app_mentions%3Aread%22%2C%22chat%3Awrite%22%2C%22channels%3Ahistory%22%2C%22groups%3Ahistory%22%2C%22im%3Ahistory%22%2C%22im%3Aread%22%2C%22im%3Awrite%22%2C%22files%3Aread%22%2C%22files%3Awrite%22%2C%22reactions%3Awrite%22%2C%22users%3Aread%22%5D%7D%7D%2C%22settings%22%3A%7B%22event_subscriptions%22%3A%7B%22bot_events%22%3A%5B%22app_mention%22%2C%22message.channels%22%2C%22message.groups%22%2C%22message.im%22%5D%7D%2C%22interactivity%22%3A%7B%22is_enabled%22%3Afalse%7D%2C%22org_deploy_enabled%22%3Afalse%2C%22socket_mode_enabled%22%3Atrue%2C%22token_rotation_enabled%22%3Afalse%7D%7D)** (opens Slack with everything filled in), pick your workspace, click **Next**, review, and click **Create**.
|
|
30
|
+
|
|
31
|
+
Or by hand: [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From a manifest** → paste [`manifest.json`](manifest.json).
|
|
32
|
+
|
|
33
|
+
<p>
|
|
34
|
+
<img src="docs/screenshots/01-pick-workspace.jpg" alt="Create from a manifest: the JSON is already filled in; pick a workspace, then Next" width="49%">
|
|
35
|
+
<img src="docs/screenshots/02-review-app.jpg" alt="Review your app, then Create" width="38%">
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
### 2. Get the two tokens
|
|
39
|
+
|
|
40
|
+
- **App token (`xapp-…`)**: **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes**, any name, scope `connections:write`.
|
|
41
|
+
- **Bot token (`xoxb-…`)**: **Install App** → **Install to Workspace** → allow, then copy the **Bot User OAuth Token**.
|
|
42
|
+
|
|
43
|
+
<details>
|
|
44
|
+
<summary>Screenshots</summary>
|
|
45
|
+
|
|
46
|
+
<img src="docs/screenshots/03-app-level-token.jpg" alt="Generate an app-level token with the connections:write scope" width="70%">
|
|
47
|
+
|
|
48
|
+
<img src="docs/screenshots/04-install-app.jpg" alt="Install App page" width="70%">
|
|
49
|
+
|
|
50
|
+
<img src="docs/screenshots/05-allow.jpg" alt="Allow the app in the workspace" width="70%">
|
|
51
|
+
|
|
52
|
+
<img src="docs/screenshots/06-bot-token.jpg" alt="Bot User OAuth Token, masked" width="70%">
|
|
53
|
+
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
### 3. Find your Slack member ID
|
|
57
|
+
|
|
58
|
+
In Slack: your profile → **⋯** → **Copy member ID** (it looks like `U012ABCDEF`). Only the IDs you list can talk to the bot.
|
|
59
|
+
|
|
60
|
+
<details>
|
|
61
|
+
<summary>Screenshot</summary>
|
|
62
|
+
|
|
63
|
+
<img src="docs/screenshots/09-member-id.jpg" alt="Profile, More, Copy member ID" width="70%">
|
|
64
|
+
|
|
65
|
+
</details>
|
|
66
|
+
|
|
67
|
+
### 4. Run it
|
|
68
|
+
|
|
69
|
+
Create a `.env` file where you run the bot:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
SLACK_APP_TOKEN=xapp-…
|
|
73
|
+
SLACK_BOT_TOKEN=xoxb-…
|
|
74
|
+
PI_SLACK_ALLOWED_USERS=U012ABCDEF,U034GHIJKL
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npx pi-mom-slack
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The bot sends you a direct message when it is up. In a channel, invite it with `/invite @pi` (the name you gave the app; the screenshots use `pi-mom-demo`), then mention it: `@pi write a script that…`. In a direct message, just write.
|
|
84
|
+
|
|
85
|
+
<details>
|
|
86
|
+
<summary>Screenshots</summary>
|
|
87
|
+
|
|
88
|
+
<img src="docs/screenshots/07-invite.jpg" alt="/invite in a channel" width="70%">
|
|
89
|
+
|
|
90
|
+
<img src="docs/screenshots/08-added-to-channel.jpg" alt="The bot has joined #demo" width="70%">
|
|
91
|
+
|
|
92
|
+
</details>
|
|
93
|
+
|
|
94
|
+
<img src="docs/screenshots/10-startup-dm.jpg" alt="Startup message: pi-mom-slack is up. First start." width="70%">
|
|
95
|
+
|
|
96
|
+
## Using it
|
|
97
|
+
|
|
98
|
+
| You do | The bot |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `@pi <request>` in a channel | starts a thread with its own pi session |
|
|
101
|
+
| reply in that thread | continues the same session, no mention needed |
|
|
102
|
+
| write to it directly | answers in a thread under your message; reply there to continue |
|
|
103
|
+
| attach files | saves them in the thread's working directory under `files/` |
|
|
104
|
+
| `!stop` | stops the current run |
|
|
105
|
+
| `!status` | idle or running (who, for how long), model, working directory |
|
|
106
|
+
| `!model` | lists the models you have credentials for |
|
|
107
|
+
| `!model sonnet` | switches this thread's model (exact `provider/id` or a unique part of it) |
|
|
108
|
+
| `!model default sonnet` | also changes pi's default model |
|
|
109
|
+
| `!help` | lists the commands |
|
|
110
|
+
|
|
111
|
+
<img src="docs/screenshots/12-server-report.jpg" alt="Running on a server: the agent writes a system report, uploads it to the thread and sums it up" width="40%">
|
|
112
|
+
<img src="docs/screenshots/11-model.jpg" alt="!model lists the models pi has credentials for" width="40%">
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
| Variable | Default | |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `SLACK_APP_TOKEN` | required | app-level token, `xapp-…` |
|
|
119
|
+
| `SLACK_BOT_TOKEN` | required | bot token, `xoxb-…` |
|
|
120
|
+
| `PI_SLACK_ALLOWED_USERS` | required | Slack member IDs, comma separated |
|
|
121
|
+
| `PI_SLACK_DATA_DIR` | `~/.pi/slack` | thread working directories, run markers, state |
|
|
122
|
+
| `PI_SLACK_ENV_FILE` | `./.env` | file read for the variables above |
|
|
123
|
+
| `PI_SLACK_DEBUG` | | logs the type of each Slack event |
|
|
124
|
+
| `PI_SLACK_SOCKET_DOWN_LIMIT_MS` | `180000` | if the Slack connection stays down this long, the process exits so a supervisor restarts it |
|
|
125
|
+
|
|
126
|
+
Variables already set in the environment win over the file. Everything read from the file, plus any variable whose name looks like a credential (`TOKEN`, `SECRET`, `API_KEY`, `PASSWORD`…), is removed from the environment of the agent's shell. pi itself still reads your provider keys.
|
|
127
|
+
|
|
128
|
+
The model, thinking level, skills and context files (`AGENTS.md`) come from pi's own configuration. pi extensions are not loaded, since many expect a terminal.
|
|
129
|
+
|
|
130
|
+
## Running it as a service
|
|
131
|
+
|
|
132
|
+
Keep **one** process per Slack app: Slack spreads events across connections at random, so a second process with the same tokens silently takes some messages. The bot warns you in a direct message when it sees more than one connection.
|
|
133
|
+
|
|
134
|
+
On a server you reach over SSH, from the folder that holds your `.env`:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
scripts/deploy-ssh.sh my-server # sends this commit, builds, copies .env, starts a user systemd service
|
|
138
|
+
ssh my-server journalctl --user -u pi-mom-slack -f
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The server needs Node.js 22.19+, pi logged in, and linger for your user (`sudo loginctl enable-linger $USER`, once), so the service keeps running after you log out. It needs no GitHub access. Run the script again to update.
|
|
142
|
+
|
|
143
|
+
Or write the user systemd unit yourself, for example:
|
|
144
|
+
|
|
145
|
+
```ini
|
|
146
|
+
# ~/.config/systemd/user/pi-mom-slack.service
|
|
147
|
+
[Unit]
|
|
148
|
+
Description=pi in Slack
|
|
149
|
+
After=network-online.target
|
|
150
|
+
|
|
151
|
+
[Service]
|
|
152
|
+
WorkingDirectory=%h/pi-slack
|
|
153
|
+
ExecStart=/usr/bin/env npx --yes pi-mom-slack@0.1.0
|
|
154
|
+
Restart=on-failure
|
|
155
|
+
|
|
156
|
+
[Install]
|
|
157
|
+
WantedBy=default.target
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
systemctl --user enable --now pi-mom-slack
|
|
162
|
+
sudo loginctl enable-linger $USER # keep it running after logout
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Pin the version and update it on purpose: the startup message lists what changed. `pi-mom-slack@latest` also works, but then every restart may run a release you have not read, including the automatic pi updates. Under systemd, `npx` must be on the unit's `PATH` (with nvm, set `Environment=PATH=…` to nvm's `bin` folder), or use `scripts/deploy-ssh.sh`, which does it for you.
|
|
166
|
+
|
|
167
|
+
## Security
|
|
168
|
+
|
|
169
|
+
The agent runs pi's tools, `bash` included, as the user running the bot, on that machine. Treat access to the bot like shell access: list only people you would give a shell to, and run it under a dedicated user or in a container if the machine holds anything sensitive. Removing credentials from the agent's shell prevents mistakes, not a determined agent.
|
|
170
|
+
|
|
171
|
+
## Extending it
|
|
172
|
+
|
|
173
|
+
The package is also a library. `createSlackBot` takes the same settings as the CLI, plus extension points:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
177
|
+
import { Type } from "typebox";
|
|
178
|
+
import { configFromEnv, createSlackBot } from "pi-mom-slack";
|
|
179
|
+
|
|
180
|
+
const bot = createSlackBot({
|
|
181
|
+
...configFromEnv(), // tokens, allowed users, data dir (or pass them yourself)
|
|
182
|
+
systemNote: "You work for the data team. Answer in English.",
|
|
183
|
+
customTools: (thread) => [ // extra pi tools, built per thread
|
|
184
|
+
defineTool({
|
|
185
|
+
name: "deploy_preview",
|
|
186
|
+
label: "Deploy a preview",
|
|
187
|
+
description: "Deploy the working directory as a preview.",
|
|
188
|
+
parameters: Type.Object({}),
|
|
189
|
+
execute: async () => ({ content: [{ type: "text", text: await deploy(thread.workspace) }], details: {} }),
|
|
190
|
+
}),
|
|
191
|
+
],
|
|
192
|
+
commands: { // !version in a thread
|
|
193
|
+
version: () => "build 42",
|
|
194
|
+
},
|
|
195
|
+
hooks: {
|
|
196
|
+
beforeRun: ({ text, user }) => text, // return a string to change what the model receives
|
|
197
|
+
afterRun: ({ thread }, result, error) => {}, // after each run, successful or not
|
|
198
|
+
onStart: ({ reason }) => {}, // once connected
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await bot.start();
|
|
203
|
+
// later: await bot.stop("SIGTERM");
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`thread` gives `key`, `channel`, `threadTs`, `workspace`, and `post(text)` / `upload(file, name)` for the thread. A complete example: [`examples/custom-bot.ts`](examples/custom-bot.ts).
|
|
207
|
+
|
|
208
|
+
## Staying current with pi
|
|
209
|
+
|
|
210
|
+
A daily GitHub Action checks for a new pi release. When the tests pass with it, a new patch version of pi-mom-slack is published; when they fail, pi itself is asked to fix the code and the fix arrives as a pull request reviewed by a human. Nothing is published without green tests. See [`.github/workflows/release.yml`](.github/workflows/release.yml). For a fork: set the `OPENROUTER_API_KEY` secret (and optionally a `PI_MODEL` variable, `provider/model`), and configure npm trusted publishing for `release.yml`.
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
npm install
|
|
216
|
+
npm test # no network: Slack is faked, pi runs against a local scripted model
|
|
217
|
+
npm run check
|
|
218
|
+
npm start # runs src/cli.ts directly
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function scrubEnv(env: NodeJS.ProcessEnv, secretNames?: Iterable<string>): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment given to the agent's shell. The bot's own secrets (everything read from the env file, the Slack tokens,
|
|
3
|
+
* and anything that looks like a credential) are removed, so a command run by the agent cannot use them by accident.
|
|
4
|
+
*
|
|
5
|
+
* This guards against mistakes, not against a hostile agent: the shell runs as the same user as the bot.
|
|
6
|
+
*/
|
|
7
|
+
const CREDENTIAL = /(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|CREDENTIAL)/i;
|
|
8
|
+
export function scrubEnv(env, secretNames = []) {
|
|
9
|
+
const out = { ...env };
|
|
10
|
+
for (const name of secretNames)
|
|
11
|
+
delete out[name];
|
|
12
|
+
for (const name of Object.keys(out))
|
|
13
|
+
if (CREDENTIAL.test(name))
|
|
14
|
+
delete out[name];
|
|
15
|
+
return out;
|
|
16
|
+
}
|
package/dist/agent.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
/** What tools, commands and hooks know about the thread they act in. */
|
|
4
|
+
export interface ThreadInfo {
|
|
5
|
+
/** Filesystem-safe id of the thread: `<channel>-<ts>`. */
|
|
6
|
+
key: string;
|
|
7
|
+
channel: string;
|
|
8
|
+
threadTs: string;
|
|
9
|
+
/** The agent's working directory for this thread. */
|
|
10
|
+
workspace: string;
|
|
11
|
+
/** Posts a message in the thread. */
|
|
12
|
+
post(text: string): Promise<void>;
|
|
13
|
+
/** Uploads a file into the thread. */
|
|
14
|
+
upload(file: Buffer, filename: string, comment?: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface RunResult {
|
|
17
|
+
text: string;
|
|
18
|
+
aborted: boolean;
|
|
19
|
+
tools: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const DEFAULT_SYSTEM_NOTE: string;
|
|
22
|
+
export declare const CONTINUE_PROMPT = "Continue: do what you just announced now, without stopping on an announcement. End with the short report (done, files, left).";
|
|
23
|
+
/** A final reply that only announces work ("I'll now…", "Je vais…") instead of reporting it. */
|
|
24
|
+
export declare function looksLikeAnnouncement(text: string): boolean;
|
|
25
|
+
export declare function threadKey(channel: string, threadTs: string): string;
|
|
26
|
+
export interface ThreadAgentsOptions {
|
|
27
|
+
dataDir: string;
|
|
28
|
+
systemNote?: string;
|
|
29
|
+
customTools?: (thread: ThreadInfo) => ToolDefinition[] | Promise<ToolDefinition[]>;
|
|
30
|
+
/** Variable names removed from the agent's shell, on top of credential-looking names. */
|
|
31
|
+
secretNames?: string[];
|
|
32
|
+
}
|
|
33
|
+
export declare class ThreadAgents {
|
|
34
|
+
private sessions;
|
|
35
|
+
private busy;
|
|
36
|
+
private processes;
|
|
37
|
+
private options;
|
|
38
|
+
constructor(options: ThreadAgentsOptions);
|
|
39
|
+
workspaceOf(key: string): string;
|
|
40
|
+
busyInfo(key: string): {
|
|
41
|
+
since: number;
|
|
42
|
+
user?: string;
|
|
43
|
+
} | undefined;
|
|
44
|
+
isBusy(key: string): boolean;
|
|
45
|
+
private session;
|
|
46
|
+
/** Runs one prompt to completion. The system note is added to the first message of a thread. */
|
|
47
|
+
run(thread: ThreadInfo, text: string, opts?: {
|
|
48
|
+
onTool?: (name: string) => void;
|
|
49
|
+
images?: ImageContent[];
|
|
50
|
+
user?: string;
|
|
51
|
+
}): Promise<RunResult>;
|
|
52
|
+
/** Model of the thread, for !status. */
|
|
53
|
+
modelLabel(thread: ThreadInfo): Promise<string>;
|
|
54
|
+
describeModels(thread: ThreadInfo): Promise<string>;
|
|
55
|
+
/** Switches the thread's model (kept in its session); with persist, also pi's default model. */
|
|
56
|
+
switchModel(thread: ThreadInfo, spec: string, persist: boolean): Promise<string>;
|
|
57
|
+
abort(key: string): Promise<boolean>;
|
|
58
|
+
disposeAll(): Promise<void>;
|
|
59
|
+
}
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One pi session per Slack thread. Each thread gets its own working directory; pi persists the session and picks it
|
|
3
|
+
* up again after a restart (SessionManager.continueRecent), so a thread keeps its context.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createAgentSession, createBashToolDefinition, DefaultResourceLoader, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { scrubEnv } from "./agent-env.js";
|
|
9
|
+
import { formatModels, label, resolveModel } from "./models.js";
|
|
10
|
+
import { RunProcesses } from "./run-processes.js";
|
|
11
|
+
import { slackUploadTool } from "./upload-tool.js";
|
|
12
|
+
export const DEFAULT_SYSTEM_NOTE = [
|
|
13
|
+
"You are pi, working for the team through Slack. Reply in the language of the user, directly and briefly. Slack renders *bold*, _italic_, `code` and ``` blocks, not Markdown headings or tables.",
|
|
14
|
+
"Your current working directory belongs to this Slack thread: create and edit your files here.",
|
|
15
|
+
"To share a file in the thread, use the slack_upload tool.",
|
|
16
|
+
"End with a short report: what is done, where the files are, what is left.",
|
|
17
|
+
].join("\n");
|
|
18
|
+
export const CONTINUE_PROMPT = "Continue: do what you just announced now, without stopping on an announcement. End with the short report (done, files, left).";
|
|
19
|
+
/** A final reply that only announces work ("I'll now…", "Je vais…") instead of reporting it. */
|
|
20
|
+
export function looksLikeAnnouncement(text) {
|
|
21
|
+
const t = text.trim();
|
|
22
|
+
if (!t || t.length > 600 || t.includes("?"))
|
|
23
|
+
return false;
|
|
24
|
+
const last = t.split(/(?<=[.!:…])\s+|\n+/).filter(Boolean).pop() ?? t;
|
|
25
|
+
return /\b(let me|i'll|i will|i'm going to|i am going to|now i|next,? i|je vais|je m'en occupe|je commence|je lance|je passe à|je m'y mets|allons-y)\b/i.test(last) || /:\s*$/.test(t);
|
|
26
|
+
}
|
|
27
|
+
export function threadKey(channel, threadTs) {
|
|
28
|
+
return `${channel}-${threadTs.replace(".", "_")}`;
|
|
29
|
+
}
|
|
30
|
+
const BUILTIN = ["read", "ls", "find", "grep", "bash", "edit", "write"];
|
|
31
|
+
const MAX_CONTINUES = 2;
|
|
32
|
+
export class ThreadAgents {
|
|
33
|
+
sessions = new Map();
|
|
34
|
+
busy = new Map();
|
|
35
|
+
processes = new Map();
|
|
36
|
+
options;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
}
|
|
40
|
+
workspaceOf(key) {
|
|
41
|
+
return join(this.options.dataDir, "threads", key);
|
|
42
|
+
}
|
|
43
|
+
busyInfo(key) {
|
|
44
|
+
return this.busy.get(key);
|
|
45
|
+
}
|
|
46
|
+
isBusy(key) {
|
|
47
|
+
return this.busy.has(key);
|
|
48
|
+
}
|
|
49
|
+
session(thread) {
|
|
50
|
+
let s = this.sessions.get(thread.key);
|
|
51
|
+
if (!s) {
|
|
52
|
+
const cwd = thread.workspace;
|
|
53
|
+
mkdirSync(cwd, { recursive: true });
|
|
54
|
+
const processes = new RunProcesses();
|
|
55
|
+
this.processes.set(thread.key, processes);
|
|
56
|
+
const secretNames = this.options.secretNames ?? [];
|
|
57
|
+
s = (async () => {
|
|
58
|
+
const loader = new DefaultResourceLoader({ cwd, agentDir: getAgentDir(), noExtensions: true });
|
|
59
|
+
await loader.reload();
|
|
60
|
+
const bash = createBashToolDefinition(cwd, { operations: processes.operations(), spawnHook: (c) => ({ ...c, env: scrubEnv(c.env, secretNames) }) });
|
|
61
|
+
const extra = (await this.options.customTools?.(thread)) ?? [];
|
|
62
|
+
const customTools = [bash, slackUploadTool(cwd, thread.upload), ...extra];
|
|
63
|
+
const tools = [...new Set([...BUILTIN, ...customTools.map((t) => t.name)])];
|
|
64
|
+
const result = await createAgentSession({ cwd, resourceLoader: loader, tools, customTools, sessionManager: SessionManager.continueRecent(cwd) });
|
|
65
|
+
return result.session;
|
|
66
|
+
})();
|
|
67
|
+
s.catch(() => this.sessions.delete(thread.key));
|
|
68
|
+
this.sessions.set(thread.key, s);
|
|
69
|
+
}
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
/** Runs one prompt to completion. The system note is added to the first message of a thread. */
|
|
73
|
+
async run(thread, text, opts = {}) {
|
|
74
|
+
this.busy.set(thread.key, { since: Date.now(), user: opts.user });
|
|
75
|
+
let tools = 0;
|
|
76
|
+
let aborted = false;
|
|
77
|
+
let unsubscribe = () => { };
|
|
78
|
+
try {
|
|
79
|
+
const session = await this.session(thread);
|
|
80
|
+
const isFirst = session.messages.length === 0;
|
|
81
|
+
const note = this.options.systemNote ?? DEFAULT_SYSTEM_NOTE;
|
|
82
|
+
const prompt = [isFirst ? note : "", text].filter(Boolean).join("\n\n");
|
|
83
|
+
unsubscribe = session.subscribe((e) => {
|
|
84
|
+
if (e.type === "tool_execution_start") {
|
|
85
|
+
tools += 1;
|
|
86
|
+
opts.onTool?.(e.toolName);
|
|
87
|
+
}
|
|
88
|
+
if (e.type === "agent_settled")
|
|
89
|
+
aborted = e.aborted;
|
|
90
|
+
});
|
|
91
|
+
const images = opts.images ?? [];
|
|
92
|
+
const readsImages = session.model?.input?.includes("image") ?? false;
|
|
93
|
+
const imageNote = images.length && !readsImages ? "\n\n(The current model does not read images: they are only saved in files/.)" : "";
|
|
94
|
+
await session.prompt(prompt + imageNote, readsImages && images.length ? { images } : undefined);
|
|
95
|
+
// Models sometimes end the turn on "I will now…": push the work through instead of leaving an announcement.
|
|
96
|
+
for (let i = 0; i < MAX_CONTINUES && !aborted && looksLikeAnnouncement(session.getLastAssistantText() ?? ""); i++) {
|
|
97
|
+
await session.prompt(CONTINUE_PROMPT);
|
|
98
|
+
}
|
|
99
|
+
return { text: session.getLastAssistantText() ?? "", aborted, tools };
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
this.processes.get(thread.key)?.cleanup();
|
|
103
|
+
unsubscribe();
|
|
104
|
+
this.busy.delete(thread.key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Model of the thread, for !status. */
|
|
108
|
+
async modelLabel(thread) {
|
|
109
|
+
const session = await this.session(thread);
|
|
110
|
+
return session.model ? label(session.model) : "(pi default)";
|
|
111
|
+
}
|
|
112
|
+
async describeModels(thread) {
|
|
113
|
+
const session = await this.session(thread);
|
|
114
|
+
return formatModels(await session.modelRuntime.getAvailable(), session.model);
|
|
115
|
+
}
|
|
116
|
+
/** Switches the thread's model (kept in its session); with persist, also pi's default model. */
|
|
117
|
+
async switchModel(thread, spec, persist) {
|
|
118
|
+
if (this.busy.has(thread.key))
|
|
119
|
+
throw new Error("A run is in progress in this thread: switch after it, or `!stop` first.");
|
|
120
|
+
const session = await this.session(thread);
|
|
121
|
+
const found = resolveModel(spec, await session.modelRuntime.getAvailable());
|
|
122
|
+
if ("error" in found)
|
|
123
|
+
throw new Error(found.error);
|
|
124
|
+
await session.setModel(found.model, { persist });
|
|
125
|
+
return persist ? `Model of this thread and pi's default: \`${label(found.model)}\`.` : `Model of this thread: \`${label(found.model)}\`.`;
|
|
126
|
+
}
|
|
127
|
+
async abort(key) {
|
|
128
|
+
const s = this.sessions.get(key);
|
|
129
|
+
if (!s || !this.busy.has(key))
|
|
130
|
+
return false;
|
|
131
|
+
await (await s).abort();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
async disposeAll() {
|
|
135
|
+
for (const s of this.sessions.values())
|
|
136
|
+
(await s.catch(() => null))?.dispose();
|
|
137
|
+
this.sessions.clear();
|
|
138
|
+
for (const p of this.processes.values())
|
|
139
|
+
p.cleanup();
|
|
140
|
+
this.processes.clear();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { SlackFile } from "./slack.ts";
|
|
3
|
+
export declare const MAX_FILE: number;
|
|
4
|
+
export interface Attachments {
|
|
5
|
+
/** Paths relative to the workspace. */
|
|
6
|
+
saved: string[];
|
|
7
|
+
images: ImageContent[];
|
|
8
|
+
errors: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function safeName(name: string, index: number): string;
|
|
11
|
+
export declare function saveAttachments(files: SlackFile[], workspace: string, download: (url: string, maxBytes: number) => Promise<Buffer>, stamp?: number): Promise<Attachments>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack attachments: every file is saved in the thread workspace (files/<stamp>/), so the agent can use it with its
|
|
3
|
+
* tools; images are also returned inline, for models that read images.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
export const MAX_FILE = 25 * 1024 * 1024;
|
|
8
|
+
const MAX_IMAGE = 5 * 1024 * 1024;
|
|
9
|
+
const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
10
|
+
export function safeName(name, index) {
|
|
11
|
+
const clean = basename(name).replace(/[^\p{L}\p{N}._-]+/gu, "_").replace(/^\.+/, "") || "file";
|
|
12
|
+
return `${String(index + 1).padStart(2, "0")}-${clean}`.slice(0, 120);
|
|
13
|
+
}
|
|
14
|
+
export async function saveAttachments(files, workspace, download, stamp = Date.now()) {
|
|
15
|
+
const out = { saved: [], images: [], errors: [] };
|
|
16
|
+
if (files.length === 0)
|
|
17
|
+
return out;
|
|
18
|
+
const rel = join("files", String(stamp));
|
|
19
|
+
mkdirSync(join(workspace, rel), { recursive: true });
|
|
20
|
+
for (const [i, f] of files.entries()) {
|
|
21
|
+
try {
|
|
22
|
+
const buf = await download(f.url, MAX_FILE);
|
|
23
|
+
const path = join(rel, safeName(f.name, i));
|
|
24
|
+
writeFileSync(join(workspace, path), buf);
|
|
25
|
+
out.saved.push(path);
|
|
26
|
+
if (IMAGE_TYPES.has(f.mimetype) && buf.length <= MAX_IMAGE)
|
|
27
|
+
out.images.push({ type: "image", data: buf.toString("base64"), mimeType: f.mimetype });
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
out.errors.push(`${f.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
package/dist/bot.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type RunResult, type ThreadInfo } from "./agent.ts";
|
|
3
|
+
export interface CommandContext {
|
|
4
|
+
thread: ThreadInfo;
|
|
5
|
+
/** Text after the command name, trimmed. */
|
|
6
|
+
args: string;
|
|
7
|
+
user: string;
|
|
8
|
+
reply(text: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/** A `!name` command. Returned text is posted in the thread. */
|
|
11
|
+
export type Command = (ctx: CommandContext) => Promise<string | void> | string | void;
|
|
12
|
+
export interface RunContext {
|
|
13
|
+
thread: ThreadInfo;
|
|
14
|
+
user: string;
|
|
15
|
+
/** Text given to the model (mentions resolved, attachments listed). */
|
|
16
|
+
text: string;
|
|
17
|
+
/** True when the run resumes after a restart. */
|
|
18
|
+
resumed: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface SlackBotHooks {
|
|
21
|
+
/** Before each run. Return a string to replace the text given to the model. */
|
|
22
|
+
beforeRun?: (ctx: RunContext) => Promise<string | void> | string | void;
|
|
23
|
+
/** After each run, successful or not (`error` is set when it failed). */
|
|
24
|
+
afterRun?: (ctx: RunContext, result: RunResult | undefined, error?: unknown) => Promise<void> | void;
|
|
25
|
+
/** Once connected to Slack. `reason` explains how the previous process ended. */
|
|
26
|
+
onStart?: (info: {
|
|
27
|
+
reason: string;
|
|
28
|
+
botUserId: string;
|
|
29
|
+
}) => Promise<void> | void;
|
|
30
|
+
}
|
|
31
|
+
export interface SlackBotOptions {
|
|
32
|
+
appToken: string;
|
|
33
|
+
botToken: string;
|
|
34
|
+
/** Slack member IDs allowed to talk to the bot. Required, never empty. */
|
|
35
|
+
allowedUsers: string[];
|
|
36
|
+
/** Root of thread workspaces, run markers and the state file. */
|
|
37
|
+
dataDir: string;
|
|
38
|
+
/** Added to the first message of every thread. Defaults to DEFAULT_SYSTEM_NOTE. */
|
|
39
|
+
systemNote?: string;
|
|
40
|
+
/** Extra pi tools, built for each thread. */
|
|
41
|
+
customTools?: (thread: ThreadInfo) => ToolDefinition[] | Promise<ToolDefinition[]>;
|
|
42
|
+
/** Extra `!name` commands (key without the "!"). Built-in commands win on a name clash. */
|
|
43
|
+
commands?: Record<string, Command>;
|
|
44
|
+
hooks?: SlackBotHooks;
|
|
45
|
+
/** Variable names removed from the agent's shell, on top of the Slack tokens and credential-looking names. */
|
|
46
|
+
secretNames?: string[];
|
|
47
|
+
/** Direct message to allowed users at startup (reason of the restart). Default true. */
|
|
48
|
+
notifyOnStart?: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface SlackBotHandle {
|
|
51
|
+
start(): Promise<void>;
|
|
52
|
+
/** Stops cleanly. `signal` is recorded in the state file for the next startup message. */
|
|
53
|
+
stop(signal?: string): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
export declare const RESUME_PROMPT = "Resume the work interrupted by the restart. Re-read the context and check the actual state before going on; say what is left if you cannot resume.";
|
|
56
|
+
export declare function createSlackBot(options: SlackBotOptions): SlackBotHandle;
|
|
57
|
+
/** Records a fatal error in the state file, so the next startup message explains it. */
|
|
58
|
+
export declare function recordCrash(dataDir: string, err: unknown): void;
|