@gugu910/pi-slack-bridge 0.1.3
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/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/activity-log.js +304 -0
- package/dist/broker/adapters/slack.js +645 -0
- package/dist/broker/adapters/types.js +3 -0
- package/dist/broker/agent-messaging.js +154 -0
- package/dist/broker/auth.js +97 -0
- package/dist/broker/client.js +495 -0
- package/dist/broker/control-plane-canvas.js +357 -0
- package/dist/broker/index.js +125 -0
- package/dist/broker/leader.js +133 -0
- package/dist/broker/maintenance.js +135 -0
- package/dist/broker/paths.js +69 -0
- package/dist/broker/router.js +287 -0
- package/dist/broker/schema.js +1492 -0
- package/dist/broker/socket-server.js +665 -0
- package/dist/broker/types.js +12 -0
- package/dist/broker-delivery.js +34 -0
- package/dist/canvases.js +175 -0
- package/dist/deploy-manifest.js +238 -0
- package/dist/follower-delivery.js +83 -0
- package/dist/git-metadata.js +95 -0
- package/dist/guardrails.js +197 -0
- package/dist/helpers.js +2128 -0
- package/dist/home-tab.js +240 -0
- package/dist/index.js +3086 -0
- package/dist/pinet-commands.js +244 -0
- package/dist/ralph-loop.js +385 -0
- package/dist/reaction-triggers.js +160 -0
- package/dist/scheduled-wakeups.js +71 -0
- package/dist/slack-api.js +5 -0
- package/dist/slack-block-kit.js +425 -0
- package/dist/slack-export.js +214 -0
- package/dist/slack-modals.js +269 -0
- package/dist/slack-presence.js +98 -0
- package/dist/slack-socket-dedup.js +143 -0
- package/dist/slack-tools.js +1715 -0
- package/dist/slack-upload.js +147 -0
- package/dist/task-assignments.js +403 -0
- package/dist/ttl-cache.js +110 -0
- package/manifest.yaml +57 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will Porcellini
|
|
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
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# slack-bridge (Pinet)
|
|
2
|
+
|
|
3
|
+
Slack assistant integration for [pi](https://github.com/badlogic/pi-mono) — multi-agent broker, thread routing, and inbox tools powered by Socket Mode.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install @gugu910/pi-slack-bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @gugu910/pi-slack-bridge
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- A Slack workspace where you have permission to install apps
|
|
20
|
+
- Node.js 22+ (uses native `fetch` and `WebSocket`)
|
|
21
|
+
- [pi](https://github.com/badlogic/pi-mono) installed
|
|
22
|
+
|
|
23
|
+
## Slack App Setup
|
|
24
|
+
|
|
25
|
+
### 1. Create the app
|
|
26
|
+
|
|
27
|
+
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App**
|
|
28
|
+
2. Choose **From a manifest**
|
|
29
|
+
3. Select your workspace
|
|
30
|
+
4. Paste the contents of [`manifest.yaml`](./manifest.yaml) from this directory
|
|
31
|
+
5. Click **Create**
|
|
32
|
+
|
|
33
|
+
The manifest configures Socket Mode, the assistant view, all required bot scopes, and event subscriptions automatically.
|
|
34
|
+
|
|
35
|
+
### 2. Generate tokens
|
|
36
|
+
|
|
37
|
+
You need two tokens:
|
|
38
|
+
|
|
39
|
+
| Token | Where to find it | Looks like |
|
|
40
|
+
| ------------------- | -------------------------------------------------------------------------------- | ------------ |
|
|
41
|
+
| **App-Level Token** | Basic Information → App-Level Tokens → Generate (with `connections:write` scope) | `xapp-1-...` |
|
|
42
|
+
| **Bot Token** | OAuth & Permissions → Install to Workspace → Bot User OAuth Token | `xoxb-...` |
|
|
43
|
+
|
|
44
|
+
### 3. Required bot scopes
|
|
45
|
+
|
|
46
|
+
These are included in the manifest, but for reference:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
app_mentions:read assistant:write bookmarks:read
|
|
50
|
+
bookmarks:write canvases:read canvases:write
|
|
51
|
+
channels:history channels:read chat:write
|
|
52
|
+
files:write groups:history groups:read
|
|
53
|
+
im:history im:read im:write
|
|
54
|
+
pins:read pins:write reactions:read
|
|
55
|
+
reactions:write users:read
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Add your tokens to `~/.pi/agent/settings.json`:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"slack-bridge": {
|
|
65
|
+
"botToken": "xoxb-your-bot-token",
|
|
66
|
+
"appToken": "xapp-your-app-token"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That's it for a minimal setup. Start pi and Pinet appears in Slack's sidebar.
|
|
72
|
+
|
|
73
|
+
### Environment variables (alternative)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
export SLACK_BOT_TOKEN="xoxb-..."
|
|
77
|
+
export SLACK_APP_TOKEN="xapp-..."
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Settings in `settings.json` take priority over env vars.
|
|
81
|
+
|
|
82
|
+
### Optional Pinet mesh auth
|
|
83
|
+
|
|
84
|
+
Shared-secret mesh auth is **optional**. You can configure it with either settings keys or environment variables:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"slack-bridge": {
|
|
89
|
+
"meshSecret": "shared-secret"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"slack-bridge": {
|
|
97
|
+
"meshSecretPath": "/Users/alice/.config/pi/pinet.secret"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
export PINET_MESH_SECRET="shared-secret"
|
|
104
|
+
# or
|
|
105
|
+
export PINET_MESH_SECRET_PATH="$HOME/.config/pi/pinet.secret"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Behavior and precedence:
|
|
109
|
+
|
|
110
|
+
- `slack-bridge.meshSecret` and `slack-bridge.meshSecretPath` override the environment fallbacks.
|
|
111
|
+
- Inline secrets win over secret paths. If `meshSecret` or `PINET_MESH_SECRET` is set, the corresponding `*Path` value is ignored.
|
|
112
|
+
- If all four values are unset, broker/follower mesh auth is disabled.
|
|
113
|
+
- A broker started with `meshSecretPath` creates the secret file if it does not exist yet.
|
|
114
|
+
- A follower started with `meshSecretPath` does **not** create the file. If the configured file is missing, follow fails with a clear error telling you to point at an existing file, provide `meshSecret` directly, or leave both unset to disable shared-secret auth.
|
|
115
|
+
- A follower configured for mesh auth will fail closed against an older/no-auth broker with a clear compatibility error. It will **not** silently retry as an unauthenticated follower.
|
|
116
|
+
|
|
117
|
+
### Full settings reference
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"slack-bridge": {
|
|
122
|
+
"botToken": "xoxb-...",
|
|
123
|
+
"appToken": "xapp-...",
|
|
124
|
+
"allowedUsers": ["U_EXAMPLE_MEMBER_ID"],
|
|
125
|
+
"defaultChannel": "C_EXAMPLE_CHANNEL_ID",
|
|
126
|
+
"logChannel": "#pinet-logs",
|
|
127
|
+
"logLevel": "actions",
|
|
128
|
+
"autoFollow": true,
|
|
129
|
+
"meshSecretPath": "/Users/alice/.config/pi/pinet.secret",
|
|
130
|
+
"suggestedPrompts": [{ "title": "Status", "message": "What are you working on?" }],
|
|
131
|
+
"security": {
|
|
132
|
+
"readOnly": false,
|
|
133
|
+
"requireConfirmation": ["slack_create_channel"],
|
|
134
|
+
"blockedTools": []
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
| Key | Required | Description |
|
|
141
|
+
| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| `botToken` | **yes** | Bot User OAuth Token (`xoxb-...`) |
|
|
143
|
+
| `appToken` | **yes** | App-Level Token for Socket Mode (`xapp-...`) |
|
|
144
|
+
| `allowedUsers` | no | Slack user IDs that can interact (all users if unset) |
|
|
145
|
+
| `defaultChannel` | no | Default channel for `slack_post_channel` |
|
|
146
|
+
| `logChannel` | no | Channel for broker activity logs |
|
|
147
|
+
| `logLevel` | no | `"errors"`, `"actions"` (default), or `"verbose"` |
|
|
148
|
+
| `autoFollow` | no | Auto-connect as follower when broker is running |
|
|
149
|
+
| `meshSecret` | no | Optional inline Pinet shared secret; overrides `meshSecretPath` and env fallbacks |
|
|
150
|
+
| `meshSecretPath` | no | Optional path to a shared-secret file; broker creates it if missing, followers require an existing file |
|
|
151
|
+
| `suggestedPrompts` | no | Prompts shown when a user opens a new conversation |
|
|
152
|
+
| `security.readOnly` | no | Block all write tools |
|
|
153
|
+
| `security.requireConfirmation` | no | Tools that need user approval before executing |
|
|
154
|
+
| `security.blockedTools` | no | Tools that are completely disabled |
|
|
155
|
+
|
|
156
|
+
## Usage
|
|
157
|
+
|
|
158
|
+
Once configured, Pinet appears in Slack's sidebar. Users open it, type a message, and the pi agent responds.
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
User opens Pinet in Slack sidebar
|
|
162
|
+
└─► types a message
|
|
163
|
+
└─► 👀 reaction appears (thinking)
|
|
164
|
+
└─► message queued for pi agent
|
|
165
|
+
└─► agent responds via slack_send
|
|
166
|
+
└─► 👀 removed, reply appears in thread
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Messages queue while the agent is busy. When the agent finishes, it automatically drains the inbox and responds.
|
|
170
|
+
|
|
171
|
+
### Available tools
|
|
172
|
+
|
|
173
|
+
| Tool | Description |
|
|
174
|
+
| ---------------------- | -------------------------------------------------------------- |
|
|
175
|
+
| `slack_send` | Reply in a Slack assistant thread |
|
|
176
|
+
| `slack_react` | Add an emoji reaction to a message |
|
|
177
|
+
| `slack_read` | Read messages from a thread |
|
|
178
|
+
| `slack_inbox` | Check pending incoming messages |
|
|
179
|
+
| `slack_upload` | Upload files, snippets, or diffs into Slack |
|
|
180
|
+
| `slack_schedule` | Schedule a message for later delivery |
|
|
181
|
+
| `slack_post_channel` | Post to a channel (by name or ID) |
|
|
182
|
+
| `slack_read_channel` | Read channel history or a thread in a channel |
|
|
183
|
+
| `slack_create_channel` | Create a new Slack channel |
|
|
184
|
+
| `slack_project_create` | Create a project channel + RFC canvas + bot invite in one call |
|
|
185
|
+
| `slack_pin` | Pin or unpin a message |
|
|
186
|
+
| `slack_bookmark` | Add, list, or remove channel bookmarks |
|
|
187
|
+
| `slack_export` | Export a thread as markdown, plain text, or JSON |
|
|
188
|
+
| `slack_presence` | Check if users are active, away, or in DND |
|
|
189
|
+
| `slack_canvas_create` | Create a standalone or channel canvas |
|
|
190
|
+
| `slack_canvas_update` | Append, prepend, or replace canvas content |
|
|
191
|
+
| `slack_blocks_build` | Build Block Kit message templates |
|
|
192
|
+
| `slack_modal_build` | Build Slack modal templates |
|
|
193
|
+
| `slack_modal_open` | Open a modal from a trigger interaction |
|
|
194
|
+
| `slack_modal_push` | Push a new step onto a modal stack |
|
|
195
|
+
| `slack_modal_update` | Update an existing open modal |
|
|
196
|
+
| `slack_confirm_action` | Request user confirmation before a dangerous action |
|
|
197
|
+
|
|
198
|
+
### Slash commands
|
|
199
|
+
|
|
200
|
+
| Command | Description |
|
|
201
|
+
| --------------- | --------------------------------------------------- |
|
|
202
|
+
| `/pinet-status` | Show connection status, threads, and agent identity |
|
|
203
|
+
| `/pinet-rename` | Change the agent's display name |
|
|
204
|
+
| `/pinet-logs` | Show recent broker activity log entries |
|
|
205
|
+
| `/slack-logs` | Show recent Slack bridge log entries |
|
|
206
|
+
|
|
207
|
+
## Pinet (Multi-Agent Mode)
|
|
208
|
+
|
|
209
|
+
Pinet supports a broker/follower architecture for coordinating multiple pi agents over Slack.
|
|
210
|
+
|
|
211
|
+
### Quick start
|
|
212
|
+
|
|
213
|
+
**Broker** (one per mesh — coordinates routing and health):
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
/pinet-start
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Follower** (workers that connect to the broker):
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
/pinet-follow
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Or set `"autoFollow": true` in settings to auto-connect when a broker is running.
|
|
226
|
+
|
|
227
|
+
### Multi-agent tools
|
|
228
|
+
|
|
229
|
+
| Tool | Description |
|
|
230
|
+
| ---------------- | ----------------------------------------------------- |
|
|
231
|
+
| `pinet_message` | Send a message to another connected agent |
|
|
232
|
+
| `pinet_agents` | List connected agents with status and capabilities |
|
|
233
|
+
| `pinet_free` | Signal that this agent is idle and available for work |
|
|
234
|
+
| `pinet_schedule` | Schedule a future wake-up message for this agent |
|
|
235
|
+
|
|
236
|
+
### Broker commands
|
|
237
|
+
|
|
238
|
+
| Command | Description |
|
|
239
|
+
| ----------------------- | ------------------------------------------ |
|
|
240
|
+
| `/pinet-start` | Start as the mesh broker |
|
|
241
|
+
| `/pinet-follow` | Connect as a follower worker |
|
|
242
|
+
| `/pinet-unfollow` | Disconnect from the broker |
|
|
243
|
+
| `/pinet-reload <agent>` | Ask another agent to reload |
|
|
244
|
+
| `/pinet-exit <agent>` | Ask another agent to exit |
|
|
245
|
+
| `/pinet-free` | Mark this agent as idle |
|
|
246
|
+
| `/pinet-skin <theme>` | Change the mesh naming theme (broker only) |
|
|
247
|
+
|
|
248
|
+
### How it works
|
|
249
|
+
|
|
250
|
+
- The **broker** runs Slack Socket Mode, routes messages to agents, monitors health via the RALPH loop, and maintains a control plane canvas
|
|
251
|
+
- **Followers** connect to the broker over a local Unix socket, poll for work, and report results
|
|
252
|
+
- Agents can optionally authenticate using a shared local secret (`meshSecret` or `meshSecretPath`); when both are unset, mesh auth is disabled
|
|
253
|
+
- Thread ownership is first-responder-wins — the first agent to reply claims the thread
|
|
254
|
+
|
|
255
|
+
## Security
|
|
256
|
+
|
|
257
|
+
- **User allowlist**: Set `allowedUsers` to restrict who can interact with Pinet
|
|
258
|
+
- **Tool guardrails**: Use `security.requireConfirmation` and `security.blockedTools` to control tool access
|
|
259
|
+
- **Mesh authentication**: Optional. Configure `meshSecret` or `meshSecretPath` (or `PINET_MESH_SECRET` / `PINET_MESH_SECRET_PATH`) to require a shared secret; leave them unset to disable shared-secret auth. Configured followers fail closed on missing secret files or older/no-auth brokers rather than silently downgrading.
|
|
260
|
+
|
|
261
|
+
Find Slack user IDs: click a user's profile → **More** → **Copy member ID**.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Development
|
|
266
|
+
|
|
267
|
+
### Build
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
pnpm run build
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Lint / Typecheck / Test
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
pnpm lint
|
|
277
|
+
pnpm typecheck
|
|
278
|
+
pnpm test
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Deploy manifest to Slack
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
pnpm deploy:slack
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Requires `appId` and `appConfigToken` in settings (or `SLACK_APP_ID` / `SLACK_APP_CONFIG_TOKEN` env vars).
|
|
288
|
+
|
|
289
|
+
### Architecture
|
|
290
|
+
|
|
291
|
+
- **Socket Mode** — outbound WebSocket, no public URL needed
|
|
292
|
+
- **Zero runtime npm deps** — native `fetch`, `WebSocket`, `node:sqlite` (Node 22+)
|
|
293
|
+
- **Hybrid inbox** — queue when busy, auto-drain when idle
|
|
294
|
+
- **Reactions** — 👀 as a lightweight "thinking" indicator
|
|
295
|
+
- **Thread persistence** — thread state survives `/reload`
|
|
296
|
+
|
|
297
|
+
## License
|
|
298
|
+
|
|
299
|
+
MIT. See [`LICENSE`](./LICENSE).
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SlackActivityLogger = void 0;
|
|
4
|
+
exports.normalizeActivityLogLevel = normalizeActivityLogLevel;
|
|
5
|
+
exports.shouldLogActivity = shouldLogActivity;
|
|
6
|
+
exports.redactSensitiveText = redactSensitiveText;
|
|
7
|
+
exports.buildActivityLogText = buildActivityLogText;
|
|
8
|
+
exports.buildActivityLogBlocks = buildActivityLogBlocks;
|
|
9
|
+
exports.buildActivityLogThreadHeader = buildActivityLogThreadHeader;
|
|
10
|
+
exports.formatRecentActivityLogEntries = formatRecentActivityLogEntries;
|
|
11
|
+
const LEVEL_RANK = {
|
|
12
|
+
errors: 0,
|
|
13
|
+
actions: 1,
|
|
14
|
+
verbose: 2,
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_MAX_RECENT_ENTRIES = 100;
|
|
17
|
+
const MAX_TEXT_LENGTH = 2800;
|
|
18
|
+
const REDACTED = "[REDACTED]";
|
|
19
|
+
function normalizeWhitespace(value) {
|
|
20
|
+
return value.replace(/\r\n/g, "\n").replaceAll("\u0000", "").trim();
|
|
21
|
+
}
|
|
22
|
+
function truncate(value, maxLength = MAX_TEXT_LENGTH) {
|
|
23
|
+
if (value.length <= maxLength) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
27
|
+
}
|
|
28
|
+
function normalizeActivityLogLevel(value) {
|
|
29
|
+
const normalized = value?.trim().toLowerCase();
|
|
30
|
+
if (normalized === "errors" || normalized === "actions" || normalized === "verbose") {
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
return "actions";
|
|
34
|
+
}
|
|
35
|
+
function shouldLogActivity(configuredLevel, eventLevel) {
|
|
36
|
+
return LEVEL_RANK[eventLevel] <= LEVEL_RANK[configuredLevel];
|
|
37
|
+
}
|
|
38
|
+
function redactSensitiveText(value) {
|
|
39
|
+
let redacted = normalizeWhitespace(value);
|
|
40
|
+
const replacements = [
|
|
41
|
+
[/xox[baprs]-[A-Za-z0-9-]+/g, REDACTED],
|
|
42
|
+
[/xoxe\.[A-Za-z0-9.-]+/g, REDACTED],
|
|
43
|
+
[/(Bearer\s+)[^\s]+/gi, `$1${REDACTED}`],
|
|
44
|
+
[
|
|
45
|
+
/\b(token|password|secret|api[_-]?key|authorization)\b\s*([:=])\s*([^\s,;]+)/gi,
|
|
46
|
+
`$1$2 ${REDACTED}`,
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
/("(?:token|password|secret|api[_-]?key|authorization)"\s*:\s*")([^"]+)(")/gi,
|
|
50
|
+
`$1${REDACTED}$3`,
|
|
51
|
+
],
|
|
52
|
+
[/(SLACK_[A-Z_]+)=([^\s]+)/g, `$1=${REDACTED}`],
|
|
53
|
+
];
|
|
54
|
+
for (const [pattern, replacement] of replacements) {
|
|
55
|
+
redacted = redacted.replace(pattern, replacement);
|
|
56
|
+
}
|
|
57
|
+
return truncate(redacted);
|
|
58
|
+
}
|
|
59
|
+
function sanitizeFieldValue(value) {
|
|
60
|
+
if (value == null) {
|
|
61
|
+
return "—";
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "boolean") {
|
|
64
|
+
return value ? "yes" : "no";
|
|
65
|
+
}
|
|
66
|
+
return redactSensitiveText(String(value));
|
|
67
|
+
}
|
|
68
|
+
function sanitizeEntry(entry, now) {
|
|
69
|
+
return {
|
|
70
|
+
...entry,
|
|
71
|
+
title: redactSensitiveText(entry.title),
|
|
72
|
+
summary: redactSensitiveText(entry.summary),
|
|
73
|
+
details: entry.details?.map((detail) => redactSensitiveText(detail)).filter(Boolean),
|
|
74
|
+
fields: entry.fields
|
|
75
|
+
?.map((field) => ({
|
|
76
|
+
label: redactSensitiveText(field.label),
|
|
77
|
+
value: sanitizeFieldValue(field.value),
|
|
78
|
+
}))
|
|
79
|
+
.filter((field) => field.label.length > 0),
|
|
80
|
+
timestamp: entry.timestamp ?? now.toISOString(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function getToneEmoji(tone) {
|
|
84
|
+
switch (tone) {
|
|
85
|
+
case "success":
|
|
86
|
+
return "✅";
|
|
87
|
+
case "warning":
|
|
88
|
+
return "⚠️";
|
|
89
|
+
case "error":
|
|
90
|
+
return "🚨";
|
|
91
|
+
case "info":
|
|
92
|
+
default:
|
|
93
|
+
return "📡";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function formatSlackTimestamp(timestamp) {
|
|
97
|
+
const date = new Date(timestamp);
|
|
98
|
+
if (Number.isNaN(date.getTime())) {
|
|
99
|
+
return timestamp;
|
|
100
|
+
}
|
|
101
|
+
return date.toISOString().replace(".000Z", "Z");
|
|
102
|
+
}
|
|
103
|
+
function buildActivityLogText(agentName, agentEmoji, entry) {
|
|
104
|
+
return `${getToneEmoji(entry.tone)} ${entry.title} — ${entry.summary} (${agentEmoji} ${agentName} · ${formatSlackTimestamp(entry.timestamp)})`;
|
|
105
|
+
}
|
|
106
|
+
function buildActivityLogBlocks(agentName, agentEmoji, entry) {
|
|
107
|
+
const blocks = [
|
|
108
|
+
{
|
|
109
|
+
type: "section",
|
|
110
|
+
text: {
|
|
111
|
+
type: "mrkdwn",
|
|
112
|
+
text: `*${getToneEmoji(entry.tone)} ${entry.title}*\n${entry.summary}`,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
const fields = (entry.fields ?? [])
|
|
117
|
+
.filter((field) => field.value !== "—")
|
|
118
|
+
.slice(0, 10)
|
|
119
|
+
.map((field) => ({
|
|
120
|
+
type: "mrkdwn",
|
|
121
|
+
text: `*${field.label}*\n${field.value}`,
|
|
122
|
+
}));
|
|
123
|
+
if (fields.length > 0) {
|
|
124
|
+
blocks.push({ type: "section", fields });
|
|
125
|
+
}
|
|
126
|
+
if (entry.details && entry.details.length > 0) {
|
|
127
|
+
blocks.push({
|
|
128
|
+
type: "section",
|
|
129
|
+
text: {
|
|
130
|
+
type: "mrkdwn",
|
|
131
|
+
text: entry.details.map((detail) => `• ${detail}`).join("\n"),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
blocks.push({
|
|
136
|
+
type: "context",
|
|
137
|
+
elements: [
|
|
138
|
+
{
|
|
139
|
+
type: "mrkdwn",
|
|
140
|
+
text: `${agentEmoji} ${agentName} · ${entry.kind} · ${formatSlackTimestamp(entry.timestamp)}`,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
return blocks;
|
|
145
|
+
}
|
|
146
|
+
function buildActivityLogThreadHeader(agentName, agentEmoji, dateKey) {
|
|
147
|
+
return {
|
|
148
|
+
text: `Pinet activity log — ${dateKey}`,
|
|
149
|
+
blocks: [
|
|
150
|
+
{
|
|
151
|
+
type: "section",
|
|
152
|
+
text: {
|
|
153
|
+
type: "mrkdwn",
|
|
154
|
+
text: `*📚 Pinet activity log — ${dateKey}*\nBroker-side coordination updates: assignments, completions, merges, stalls, and RALPH maintenance events.`,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
type: "context",
|
|
159
|
+
elements: [
|
|
160
|
+
{
|
|
161
|
+
type: "mrkdwn",
|
|
162
|
+
text: `${agentEmoji} ${agentName} · daily thread`,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function formatRecentActivityLogEntries(entries) {
|
|
170
|
+
if (entries.length === 0) {
|
|
171
|
+
return "No activity log entries recorded in this session.";
|
|
172
|
+
}
|
|
173
|
+
return entries
|
|
174
|
+
.map((entry) => `[${formatSlackTimestamp(entry.timestamp)}] ${entry.title} — ${entry.summary}`)
|
|
175
|
+
.join("\n");
|
|
176
|
+
}
|
|
177
|
+
class SlackActivityLogger {
|
|
178
|
+
deps;
|
|
179
|
+
queue = [];
|
|
180
|
+
recent = [];
|
|
181
|
+
maxRecentEntries;
|
|
182
|
+
flushTimer = null;
|
|
183
|
+
flushRunning = false;
|
|
184
|
+
resolvedChannelCache = null;
|
|
185
|
+
dailyThreadCache = null;
|
|
186
|
+
constructor(deps) {
|
|
187
|
+
this.deps = deps;
|
|
188
|
+
this.maxRecentEntries = deps.maxRecentEntries ?? DEFAULT_MAX_RECENT_ENTRIES;
|
|
189
|
+
}
|
|
190
|
+
log(entry) {
|
|
191
|
+
const rawChannel = this.deps.getLogChannel()?.trim();
|
|
192
|
+
if (!rawChannel) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const configuredLevel = normalizeActivityLogLevel(this.deps.getLogLevel());
|
|
196
|
+
if (!shouldLogActivity(configuredLevel, entry.level)) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const sanitized = sanitizeEntry(entry, this.getNow());
|
|
200
|
+
this.recent.unshift(sanitized);
|
|
201
|
+
this.recent.splice(this.maxRecentEntries);
|
|
202
|
+
this.queue.push({ entry: sanitized, attempts: 0 });
|
|
203
|
+
this.scheduleFlush(0);
|
|
204
|
+
}
|
|
205
|
+
getRecentEntries(limit = 20) {
|
|
206
|
+
return this.recent.slice(0, Math.max(0, limit));
|
|
207
|
+
}
|
|
208
|
+
clearPending() {
|
|
209
|
+
this.queue.splice(0, this.queue.length);
|
|
210
|
+
this.resolvedChannelCache = null;
|
|
211
|
+
this.dailyThreadCache = null;
|
|
212
|
+
if (this.flushTimer) {
|
|
213
|
+
clearTimeout(this.flushTimer);
|
|
214
|
+
this.flushTimer = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
getNow() {
|
|
218
|
+
return this.deps.now ? this.deps.now() : new Date();
|
|
219
|
+
}
|
|
220
|
+
scheduleFlush(delayMs) {
|
|
221
|
+
if (this.flushTimer || this.flushRunning || this.queue.length === 0) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.flushTimer = setTimeout(() => {
|
|
225
|
+
this.flushTimer = null;
|
|
226
|
+
void this.flushNext();
|
|
227
|
+
}, delayMs);
|
|
228
|
+
this.flushTimer.unref?.();
|
|
229
|
+
}
|
|
230
|
+
async flushNext() {
|
|
231
|
+
if (this.flushRunning) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const next = this.queue.shift();
|
|
235
|
+
if (!next) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.flushRunning = true;
|
|
239
|
+
try {
|
|
240
|
+
await this.postEntry(next.entry);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (next.attempts < 2) {
|
|
244
|
+
this.queue.unshift({ entry: next.entry, attempts: next.attempts + 1 });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
this.deps.onError?.(error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
this.flushRunning = false;
|
|
252
|
+
if (this.queue.length > 0) {
|
|
253
|
+
this.scheduleFlush(1000);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async resolveLogChannel(rawChannel) {
|
|
258
|
+
if (this.resolvedChannelCache?.raw === rawChannel) {
|
|
259
|
+
return this.resolvedChannelCache.id;
|
|
260
|
+
}
|
|
261
|
+
const channelId = await this.deps.resolveChannel(rawChannel);
|
|
262
|
+
this.resolvedChannelCache = { raw: rawChannel, id: channelId };
|
|
263
|
+
return channelId;
|
|
264
|
+
}
|
|
265
|
+
async ensureDailyThread(rawChannel, channelId) {
|
|
266
|
+
const dateKey = this.getNow().toISOString().slice(0, 10);
|
|
267
|
+
if (this.dailyThreadCache?.rawChannel === rawChannel &&
|
|
268
|
+
this.dailyThreadCache.dateKey === dateKey) {
|
|
269
|
+
return this.dailyThreadCache.threadTs;
|
|
270
|
+
}
|
|
271
|
+
const token = this.deps.getBotToken();
|
|
272
|
+
if (!token) {
|
|
273
|
+
throw new Error("Slack bot token unavailable for activity logging.");
|
|
274
|
+
}
|
|
275
|
+
const heading = buildActivityLogThreadHeader(this.deps.getAgentName(), this.deps.getAgentEmoji(), dateKey);
|
|
276
|
+
const response = await this.deps.slack("chat.postMessage", token, {
|
|
277
|
+
channel: channelId,
|
|
278
|
+
text: heading.text,
|
|
279
|
+
blocks: heading.blocks,
|
|
280
|
+
});
|
|
281
|
+
const threadTs = typeof response.ts === "string" ? response.ts : null;
|
|
282
|
+
if (!threadTs) {
|
|
283
|
+
throw new Error("Slack activity log thread creation did not return a ts.");
|
|
284
|
+
}
|
|
285
|
+
this.dailyThreadCache = { rawChannel, dateKey, threadTs };
|
|
286
|
+
return threadTs;
|
|
287
|
+
}
|
|
288
|
+
async postEntry(entry) {
|
|
289
|
+
const rawChannel = this.deps.getLogChannel()?.trim();
|
|
290
|
+
const token = this.deps.getBotToken();
|
|
291
|
+
if (!rawChannel || !token) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const channelId = await this.resolveLogChannel(rawChannel);
|
|
295
|
+
const threadTs = await this.ensureDailyThread(rawChannel, channelId);
|
|
296
|
+
await this.deps.slack("chat.postMessage", token, {
|
|
297
|
+
channel: channelId,
|
|
298
|
+
thread_ts: threadTs,
|
|
299
|
+
text: buildActivityLogText(this.deps.getAgentName(), this.deps.getAgentEmoji(), entry),
|
|
300
|
+
blocks: buildActivityLogBlocks(this.deps.getAgentName(), this.deps.getAgentEmoji(), entry),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
exports.SlackActivityLogger = SlackActivityLogger;
|