@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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +299 -0
  3. package/dist/activity-log.js +304 -0
  4. package/dist/broker/adapters/slack.js +645 -0
  5. package/dist/broker/adapters/types.js +3 -0
  6. package/dist/broker/agent-messaging.js +154 -0
  7. package/dist/broker/auth.js +97 -0
  8. package/dist/broker/client.js +495 -0
  9. package/dist/broker/control-plane-canvas.js +357 -0
  10. package/dist/broker/index.js +125 -0
  11. package/dist/broker/leader.js +133 -0
  12. package/dist/broker/maintenance.js +135 -0
  13. package/dist/broker/paths.js +69 -0
  14. package/dist/broker/router.js +287 -0
  15. package/dist/broker/schema.js +1492 -0
  16. package/dist/broker/socket-server.js +665 -0
  17. package/dist/broker/types.js +12 -0
  18. package/dist/broker-delivery.js +34 -0
  19. package/dist/canvases.js +175 -0
  20. package/dist/deploy-manifest.js +238 -0
  21. package/dist/follower-delivery.js +83 -0
  22. package/dist/git-metadata.js +95 -0
  23. package/dist/guardrails.js +197 -0
  24. package/dist/helpers.js +2128 -0
  25. package/dist/home-tab.js +240 -0
  26. package/dist/index.js +3086 -0
  27. package/dist/pinet-commands.js +244 -0
  28. package/dist/ralph-loop.js +385 -0
  29. package/dist/reaction-triggers.js +160 -0
  30. package/dist/scheduled-wakeups.js +71 -0
  31. package/dist/slack-api.js +5 -0
  32. package/dist/slack-block-kit.js +425 -0
  33. package/dist/slack-export.js +214 -0
  34. package/dist/slack-modals.js +269 -0
  35. package/dist/slack-presence.js +98 -0
  36. package/dist/slack-socket-dedup.js +143 -0
  37. package/dist/slack-tools.js +1715 -0
  38. package/dist/slack-upload.js +147 -0
  39. package/dist/task-assignments.js +403 -0
  40. package/dist/ttl-cache.js +110 -0
  41. package/manifest.yaml +57 -0
  42. 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;