@grknbyk/agent-wire 0.6.0 → 0.6.1

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/README.md CHANGED
@@ -316,7 +316,7 @@ a 0.4 agent cannot verify each other. Upgrade both ends together.
316
316
  ## Development
317
317
 
318
318
  ```bash
319
- npm test # 53 tests, no network
319
+ npm test # 60 tests, no network
320
320
  npm run bench # medians over a synthetic 20k-message log
321
321
  ```
322
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grknbyk/agent-wire",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Let AI coding agents message each other through a shared Slack channel, over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/mcp.mjs CHANGED
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path';
10
10
  import { activeChannels, channelMode, findChannel, loadConfig, paths, pollableChannels } from './config.mjs';
11
11
  import { DEFAULT_COUNT, appendMessages, archive, findByTs, markRead, readCursor, selectMessages, writeCursor } from './inbox.mjs';
12
12
  import { FINGERPRINT_CHARS, listPeers, signMessage } from './identity.mjs';
13
- import { listMembers, pollChannel, postMessage, slackClient, uploadFile } from './slack.mjs';
13
+ import { CHANNEL_CONCURRENCY, listMembers, mapLimit, pollChannel, postMessage, slackClient, uploadFile } from './slack.mjs';
14
14
  import { MAX_HOPS, TEXT_MAX, formatMessage, mintNonce, renderEnvelope } from './protocol.mjs';
15
15
 
16
16
  const POLL_EVERY_MS = 5000;
@@ -134,18 +134,26 @@ function claimsPoll() {
134
134
  return true;
135
135
  }
136
136
 
137
+ // The channels are fetched together and written afterwards, in order. Awaiting
138
+ // one channel before starting the next spent a round trip per channel on data
139
+ // that has nothing to do with the previous answer. Writing afterwards also means
140
+ // no two channels interleave a read-modify-write of the same log.
141
+ //
142
+ // A channel that throws is caught here rather than at the caller, so one broken
143
+ // channel costs its own messages instead of everybody else's.
137
144
  export async function pollOnce(config) {
138
145
  const client = slackClient(config.bot_token);
146
+ const channels = pollableChannels(config);
147
+ const polled = await mapLimit(channels, CHANNEL_CONCURRENCY, (channel) =>
148
+ pollChannel(client, channel, { since: readCursor(channel.id), myNickname: config.nickname })
149
+ .catch((error) => ({ ok: false, reason: error.message, items: [] })));
150
+
139
151
  let added = 0;
140
- for (const channel of pollableChannels(config)) {
141
- const result = await pollChannel(client, channel, {
142
- since: readCursor(channel.id),
143
- myNickname: config.nickname,
144
- });
152
+ for (const [index, result] of polled.entries()) {
145
153
  if (!result.ok) continue;
146
154
 
147
155
  added += appendMessages(result.items);
148
- if (result.newest) writeCursor(channel.id, result.newest);
156
+ if (result.newest) writeCursor(channels[index].id, result.newest);
149
157
  }
150
158
  return added;
151
159
  }
package/src/slack.mjs CHANGED
@@ -24,6 +24,34 @@ const NAME_MAX_CHARS = 80;
24
24
  // message says why it was left there.
25
25
  const DOWNLOAD_MAX_BYTES = 20 * 1024 * 1024;
26
26
 
27
+ // How many requests of one kind may be in flight. Slack rate-limits per method,
28
+ // so these are per loop rather than one global number: history is Tier 3, the
29
+ // user lookup is Tier 4, and a download is not an API call at all but does hold
30
+ // a whole file in memory while it lands.
31
+ export const CHANNEL_CONCURRENCY = 4;
32
+ const USER_CONCURRENCY = 8;
33
+ const DOWNLOAD_CONCURRENCY = 3;
34
+
35
+ // Node has no bounded Promise.all, and both ends of the choice are wrong here:
36
+ // awaiting in a loop costs one round trip per item, and an unbounded Promise.all
37
+ // throws two hundred requests at a rate limiter. The workers pull from one shared
38
+ // cursor rather than taking a slice each, so a slow reply cannot leave the others
39
+ // queued behind it.
40
+ export async function mapLimit(items, limit, run) {
41
+ const results = new Array(items.length);
42
+ let next = 0;
43
+
44
+ const worker = async () => {
45
+ while (next < items.length) {
46
+ const index = next++;
47
+ results[index] = await run(items[index], index);
48
+ }
49
+ };
50
+
51
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
52
+ return results;
53
+ }
54
+
27
55
  // conversations.* reject a JSON body and chat.postMessage needs one for metadata,
28
56
  // so the client speaks both and the caller picks per method. The token rides along
29
57
  // because downloading a file is a plain fetch, not an API call.
@@ -165,12 +193,8 @@ async function downloadAttachment(client, file) {
165
193
  }
166
194
 
167
195
  async function downloadAll(client, files) {
168
- const saved = [];
169
- for (const file of files ?? []) {
170
- const result = await downloadAttachment(client, file);
171
- if (result) saved.push(result);
172
- }
173
- return saved;
196
+ const saved = await mapLimit(files ?? [], DOWNLOAD_CONCURRENCY, (file) => downloadAttachment(client, file));
197
+ return saved.filter(Boolean);
174
198
  }
175
199
 
176
200
  async function downloadById(client, fileId) {
@@ -191,13 +215,15 @@ async function resolveUserNames(client, userIds) {
191
215
  const missing = [...new Set(userIds)].filter((userId) => !known[userId]);
192
216
  if (missing.length === 0) return userIds.map((userId) => known[userId]);
193
217
 
194
- const found = { ...known };
195
- for (const userId of missing) {
218
+ const resolved = await mapLimit(missing, USER_CONCURRENCY, async (userId) => {
196
219
  const result = await client.form('users.info', { user: userId });
197
- found[userId] = result.ok
220
+ return result.ok
198
221
  ? (result.user.profile?.display_name || result.user.real_name || userId)
199
222
  : userId;
200
- }
223
+ });
224
+
225
+ const found = { ...known };
226
+ missing.forEach((userId, index) => { found[userId] = resolved[index]; });
201
227
  writeJson(paths.users, found);
202
228
  return userIds.map((userId) => found[userId]);
203
229
  }