hive-am 1.5.0 → 1.6.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.
Files changed (134) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +5 -4
  3. package/package.json +1 -1
  4. package/server/dist/connections/format.js +26 -0
  5. package/server/dist/connections/index.js +2 -0
  6. package/server/dist/connections/manager.js +3 -3
  7. package/server/dist/connections/router.js +42 -12
  8. package/server/dist/connections/secrets.js +1 -1
  9. package/server/dist/connections/slack.js +488 -0
  10. package/server/dist/connections/store.js +2 -0
  11. package/server/dist/db/migrations.js +12 -0
  12. package/server/dist/db.js +15 -3
  13. package/server/dist/dispatches.js +126 -0
  14. package/server/dist/http/routes/orchestration.js +13 -2
  15. package/server/dist/http/validate.js +22 -2
  16. package/server/dist/index.js +2 -0
  17. package/server/dist/instructions.js +1 -1
  18. package/server/dist/retention.js +4 -2
  19. package/server/dist/runtime.js +17 -6
  20. package/server/dist/services/connections.js +18 -3
  21. package/server/dist/skills/defaults.js +6 -4
  22. package/server/dist/watch.js +1 -1
  23. package/server/mcp/dispatch.mjs +48 -5
  24. package/web/.next/BUILD_ID +1 -1
  25. package/web/.next/app-build-manifest.json +92 -76
  26. package/web/.next/app-path-routes-manifest.json +6 -6
  27. package/web/.next/build-manifest.json +2 -2
  28. package/web/.next/prerender-manifest.json +53 -53
  29. package/web/.next/server/app/_not-found/page.js +2 -2
  30. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  31. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  32. package/web/.next/server/app/_not-found.html +1 -1
  33. package/web/.next/server/app/_not-found.rsc +3 -3
  34. package/web/.next/server/app/agents/[id]/page.js +4 -4
  35. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  36. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  37. package/web/.next/server/app/agents/page.js +2 -2
  38. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  39. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  40. package/web/.next/server/app/agents.html +1 -1
  41. package/web/.next/server/app/agents.rsc +21 -18
  42. package/web/.next/server/app/chat/page.js +2 -2
  43. package/web/.next/server/app/chat/page.js.nft.json +1 -1
  44. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
  45. package/web/.next/server/app/chat.html +1 -1
  46. package/web/.next/server/app/chat.rsc +4 -4
  47. package/web/.next/server/app/connections/page.js +2 -2
  48. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  49. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  50. package/web/.next/server/app/connections.html +1 -1
  51. package/web/.next/server/app/connections.rsc +4 -4
  52. package/web/.next/server/app/index.html +1 -1
  53. package/web/.next/server/app/index.rsc +4 -4
  54. package/web/.next/server/app/page.js +2 -2
  55. package/web/.next/server/app/page.js.nft.json +1 -1
  56. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  57. package/web/.next/server/app/relations/page.js +2 -2
  58. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  59. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  60. package/web/.next/server/app/relations.html +1 -1
  61. package/web/.next/server/app/relations.rsc +4 -4
  62. package/web/.next/server/app/resources/[id]/page.js +2 -2
  63. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
  64. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  65. package/web/.next/server/app/resources/page.js +2 -2
  66. package/web/.next/server/app/resources/page.js.nft.json +1 -1
  67. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  68. package/web/.next/server/app/resources.html +1 -1
  69. package/web/.next/server/app/resources.rsc +4 -4
  70. package/web/.next/server/app/schedules/page.js +2 -2
  71. package/web/.next/server/app/schedules/page.js.nft.json +1 -1
  72. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  73. package/web/.next/server/app/schedules.html +1 -1
  74. package/web/.next/server/app/schedules.rsc +4 -4
  75. package/web/.next/server/app/sessions/page.js +2 -2
  76. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  77. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  78. package/web/.next/server/app/sessions.html +1 -1
  79. package/web/.next/server/app/sessions.rsc +4 -4
  80. package/web/.next/server/app/skills/page.js +2 -2
  81. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  82. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  83. package/web/.next/server/app/skills.html +1 -1
  84. package/web/.next/server/app/skills.rsc +4 -4
  85. package/web/.next/server/app/types/page.js +2 -2
  86. package/web/.next/server/app/types/page.js.nft.json +1 -1
  87. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  88. package/web/.next/server/app/types.html +1 -1
  89. package/web/.next/server/app/types.rsc +4 -4
  90. package/web/.next/server/app-paths-manifest.json +6 -6
  91. package/web/.next/server/chunks/{505.js → 111.js} +1 -1
  92. package/web/.next/server/chunks/143.js +1 -1
  93. package/web/.next/server/chunks/741.js +2 -0
  94. package/web/.next/server/chunks/837.js +1 -0
  95. package/web/.next/server/chunks/{435.js → 939.js} +3 -3
  96. package/web/.next/server/pages/404.html +1 -1
  97. package/web/.next/server/pages/500.html +1 -1
  98. package/web/.next/server/server-reference-manifest.json +1 -1
  99. package/web/.next/static/chunks/162-68ba190086f5ad39.js +1 -0
  100. package/web/.next/static/chunks/214-35b88db02113ae02.js +1 -0
  101. package/web/.next/static/chunks/281-4506bc98b7111729.js +1 -0
  102. package/web/.next/static/chunks/491-196bcb3dd11bb460.js +1 -0
  103. package/web/.next/static/chunks/517-8f2db464d10a4624.js +1 -0
  104. package/web/.next/static/chunks/{586-f7017d585a84738d.js → 586-4f67b823df060b46.js} +1 -1
  105. package/web/.next/static/chunks/{587-5b511f5f93910180.js → 587-3c53d91a1c4f99d6.js} +1 -1
  106. package/web/.next/static/chunks/642-dc874c8caf203ee1.js +1 -0
  107. package/web/.next/static/chunks/771-db19da9b1a24820c.js +1 -0
  108. package/web/.next/static/chunks/app/agents/[id]/page-803d53aedd10c8e5.js +1 -0
  109. package/web/.next/static/chunks/app/agents/layout-71dd98d696935e29.js +1 -0
  110. package/web/.next/static/chunks/app/connections/page-b0ea2eae62cef74e.js +1 -0
  111. package/web/.next/static/chunks/app/layout-cb5af29b0742c513.js +1 -0
  112. package/web/.next/static/chunks/app/page-5e547e87de23d9fa.js +1 -0
  113. package/web/.next/static/chunks/app/resources/[id]/page-1e2afddcb1f04884.js +1 -0
  114. package/web/.next/static/chunks/app/resources/{page-61cccd8fdcc2f6c8.js → page-195b0af83b8ace0d.js} +1 -1
  115. package/web/.next/static/chunks/app/types/page-c4c2b2217c4667e8.js +1 -0
  116. package/web/.next/static/css/c7faf690373117bb.css +1 -0
  117. package/web/.next/trace +30 -30
  118. package/web/.next/types/routes.d.ts +2 -1
  119. package/web/.next/types/validator.ts +9 -0
  120. package/web/.next/server/chunks/274.js +0 -1
  121. package/web/.next/static/chunks/107-77dd87cc2ca4f941.js +0 -1
  122. package/web/.next/static/chunks/281-30e32d932b8b5a75.js +0 -1
  123. package/web/.next/static/chunks/391-18768499e741112e.js +0 -1
  124. package/web/.next/static/chunks/517-b3b1c9044d2ef23c.js +0 -1
  125. package/web/.next/static/chunks/656-f3cc78328603241a.js +0 -1
  126. package/web/.next/static/chunks/app/agents/[id]/page-638e12ba3cb813c3.js +0 -1
  127. package/web/.next/static/chunks/app/connections/page-06bd2d7d9495126a.js +0 -1
  128. package/web/.next/static/chunks/app/layout-fad968a635f9a4b3.js +0 -1
  129. package/web/.next/static/chunks/app/page-0062ebc30322b1ae.js +0 -1
  130. package/web/.next/static/chunks/app/resources/[id]/page-fc494e85b01e8cb7.js +0 -1
  131. package/web/.next/static/chunks/app/types/page-8e25f503bdcd72ad.js +0 -1
  132. package/web/.next/static/css/dfcaa19a8d46e60d.css +0 -1
  133. /package/web/.next/static/{UDA414PFPu9ZROltNmjbX → tdnUXVa01m25EjOobxVd1}/_buildManifest.js +0 -0
  134. /package/web/.next/static/{UDA414PFPu9ZROltNmjbX → tdnUXVa01m25EjOobxVd1}/_ssgManifest.js +0 -0
@@ -0,0 +1,488 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import WebSocket from 'ws';
3
+ import { MAX_FILE_BYTES } from './files.js';
4
+ import { mdToSlackMrkdwn, splitMessage } from './format.js';
5
+ import { threads } from './store.js';
6
+ const MAX_PART = 3500; // Slack takes far more, but long messages are cut in the timeline: keep each part readable
7
+ const MIN_GAP_MS = 1000; // Slack allows about one message per second per channel
8
+ const CALL_TIMEOUT_MS = Number(process.env.HIVE_AM_SLACK_TIMEOUT_MS) || 30_000;
9
+ const UPLOAD_TIMEOUT_MS = 120_000;
10
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
11
+ const HELLO_TIMEOUT_MS = 15_000; // a socket that does not say hello in this long is not going to
12
+ const PING_EVERY_MS = 30_000; // a half-open socket never errors by itself: ping it, and drop it when it does not answer
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ const within = (ms, signal) => (signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms));
15
+ const timedOut = (e) => e instanceof Error && e.name === 'TimeoutError';
16
+ /**
17
+ * The emoji the agent may react with on Slack, and the name Slack knows each one by (`reactions.add` takes names, not characters).
18
+ * Slack accepts any standard emoji; this list is the useful ones, without the rude.
19
+ */
20
+ export const SLACK_REACTION_NAMES = {
21
+ '👍': '+1', '👎': '-1', '❤': 'heart', '🔥': 'fire', '👏': 'clap', '😁': 'grin', '😂': 'joy', '🤣': 'rolling_on_the_floor_laughing', '😅': 'sweat_smile',
22
+ '🤔': 'thinking_face', '🤯': 'exploding_head', '😱': 'scream', '😢': 'cry', '😭': 'sob', '🎉': 'tada', '🤩': 'star-struck', '🙏': 'pray', '👌': 'ok_hand',
23
+ '😍': 'heart_eyes', '💯': '100', '⚡': 'zap', '🏆': 'trophy', '💔': 'broken_heart', '😐': 'neutral_face', '😴': 'sleeping', '👀': 'eyes', '🙈': 'see_no_evil',
24
+ '😇': 'innocent', '🤝': 'handshake', '✍': 'writing_hand', '🤗': 'hugging_face', '🫡': 'saluting_face', '😎': 'sunglasses', '🤷': 'shrug', '🤓': 'nerd_face',
25
+ '✅': 'white_check_mark', '☑': 'ballot_box_with_check', '✔': 'heavy_check_mark', '❌': 'x', '⚠': 'warning', '🚀': 'rocket', '👋': 'wave', '🙌': 'raised_hands',
26
+ '💪': 'muscle', '⏳': 'hourglass_flowing_sand', '🐛': 'bug', '📌': 'pushpin', '⭐': 'star', '🙂': 'slightly_smiling_face', '😊': 'blush', '😉': 'wink', '🆗': 'ok',
27
+ '➕': 'heavy_plus_sign', '🧐': 'face_with_monocle', '💡': 'bulb', '📝': 'memo', '🔍': 'mag', '🛠': 'hammer_and_wrench', '🥳': 'partying_face', '😬': 'grimacing',
28
+ // The rest of Telegram's list, so an agent that learnt that list is never refused on Slack.
29
+ '🥰': 'smiling_face_with_3_hearts', '🕊': 'dove_of_peace', '🤡': 'clown_face', '🥱': 'yawning_face', '🥴': 'woozy_face', '🐳': 'whale', '❤‍🔥': 'heart_on_fire',
30
+ '🌚': 'new_moon_with_face', '🌭': 'hotdog', '🍌': 'banana', '🤨': 'face_with_raised_eyebrow', '🍓': 'strawberry', '🍾': 'champagne', '💋': 'kiss', '😈': 'smiling_imp',
31
+ '👻': 'ghost', '👨‍💻': 'male-technologist', '🎃': 'jack_o_lantern', '😨': 'fearful', '🎅': 'santa', '🎄': 'christmas_tree', '☃': 'snowman', '💅': 'nail_care',
32
+ '🤪': 'zany_face', '🗿': 'moyai', '🆒': 'cool', '💘': 'cupid', '🙉': 'hear_no_evil', '🦄': 'unicorn_face', '😘': 'kissing_heart', '💊': 'pill', '🙊': 'speak_no_evil',
33
+ '👾': 'space_invader', '🤷‍♂': 'man-shrugging', '🤷‍♀': 'woman-shrugging',
34
+ };
35
+ export const SLACK_REACTIONS = Object.keys(SLACK_REACTION_NAMES);
36
+ const NAME_OF = new Map(Object.entries(SLACK_REACTION_NAMES).map(([e, n]) => [e.replace(/️/g, ''), n]));
37
+ const KNOWN_NAMES = new Set([...Object.values(SLACK_REACTION_NAMES), 'thumbsup', 'thumbsdown']);
38
+ /** The commands hive-am understands (router.ts). On Slack they may come as their own slash command, as `/hive-status`, or as `/hive status`. */
39
+ const COMMANDS = new Set(['new', 'clear', 'reset', 'stop', 'status', 'mute', 'unmute', 'help', 'id']);
40
+ export function slashToCommand(command, text) {
41
+ const name = command.replace(/^\//, '').toLowerCase(), args = text.trim();
42
+ if (COMMANDS.has(name))
43
+ return { name, args };
44
+ const suffix = /[-_](\w+)$/.exec(name)?.[1];
45
+ if (suffix && COMMANDS.has(suffix))
46
+ return { name: suffix, args };
47
+ const [first = 'help', ...rest] = args ? args.split(/\s+/) : [];
48
+ return { name: first.replace(/^\//, '').toLowerCase(), args: rest.join(' ') };
49
+ }
50
+ class SlackError extends Error {
51
+ code;
52
+ method;
53
+ retryAfter;
54
+ constructor(msg, code, method, retryAfter) {
55
+ super(msg);
56
+ this.code = code;
57
+ this.method = method;
58
+ this.retryAfter = retryAfter;
59
+ }
60
+ }
61
+ const AUTH_ERRORS = new Set(['invalid_auth', 'not_authed', 'account_inactive', 'token_revoked', 'token_expired']);
62
+ /** What a Slack API error means to the person setting up the connection. */
63
+ function readable(e, method = e.method) {
64
+ if (AUTH_ERRORS.has(e.code))
65
+ return method === 'apps.connections.open' ? 'Invalid app token' : 'Invalid bot token';
66
+ if (e.code === 'not_allowed_token_type')
67
+ return method === 'apps.connections.open' ? 'The app token must be an app-level token (xapp-…) with connections:write' : 'The bot token must be a bot token (xoxb-…)';
68
+ if (e.code === 'missing_scope')
69
+ return `The Slack app lacks a permission for ${method}: add the scopes from the manual and reinstall it`;
70
+ if (e.code === 'not_in_channel')
71
+ return 'The app is not in this channel: invite it with /invite @your-app';
72
+ if (e.code === 'channel_not_found')
73
+ return 'Slack does not know this channel (or the app cannot see it)';
74
+ return e.message;
75
+ }
76
+ /** The files of a Slack message (they come in the event itself, so there is nothing to assemble). The download URL is the attachment's id. */
77
+ function attachmentsOf(ev) {
78
+ const out = [];
79
+ for (const f of (Array.isArray(ev.files) ? ev.files : [])) {
80
+ const url = f.url_private_download ?? f.url_private;
81
+ if (!url || f.mode === 'tombstone' || f.file_access === 'check_file_info')
82
+ continue;
83
+ const mime = String(f.mimetype ?? '');
84
+ const kind = f.subtype === 'slack_audio' ? 'voice' : mime.startsWith('image/') ? 'image' : mime.startsWith('audio/') ? 'audio' : mime.startsWith('video/') ? 'video' : 'document';
85
+ out.push({ id: url, name: f.name ?? f.title ?? `file-${f.id}`, kind, mime: mime || undefined, size: f.size });
86
+ }
87
+ return out;
88
+ }
89
+ /** Slack Web API over plain `fetch` and Socket Mode over a WebSocket: events come through a socket this machine opens, so no public URL is needed. */
90
+ export class SlackAdapter {
91
+ conn;
92
+ kind = 'slack';
93
+ state = { state: 'connecting' };
94
+ me = null;
95
+ stopped = false;
96
+ abort = new AbortController();
97
+ ws = null;
98
+ reconnecting = false;
99
+ pinger;
100
+ alive = true;
101
+ onMessage = null;
102
+ order = Promise.resolve(); // events are parsed one after another, so they reach the router in the order they came
103
+ lastSend = new Map();
104
+ names = new Map(); // user id → display name
105
+ places = new Map(); // channel id → `#name`
106
+ constructor(conn) {
107
+ this.conn = conn;
108
+ }
109
+ get base() { return (this.conn.config.api_base || 'https://slack.com/api').replace(/\/$/, ''); }
110
+ /** One Web API call, form-encoded (every method takes it; objects go as JSON, as Slack expects). */
111
+ async api(method, params = {}, token = this.conn.config.bot_token) {
112
+ const body = new URLSearchParams();
113
+ for (const [k, v] of Object.entries(params))
114
+ if (v !== undefined && v !== null)
115
+ body.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
116
+ let res;
117
+ try {
118
+ res = await fetch(`${this.base}/${method}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/x-www-form-urlencoded' }, body, signal: within(CALL_TIMEOUT_MS, this.abort.signal) });
119
+ }
120
+ catch (e) {
121
+ throw timedOut(e) ? new SlackError(`Slack ${method} did not answer in time`, 'timeout', method) : e;
122
+ }
123
+ if (res.status === 429)
124
+ throw new SlackError(`Slack ${method} is rate limited`, 'ratelimited', method, Number(res.headers.get('retry-after')) || 1);
125
+ const j = await res.json().catch(() => ({}));
126
+ if (!j.ok)
127
+ throw new SlackError(j.error ? `Slack ${method}: ${j.error}` : `Slack ${method} failed (${res.status})`, j.error ?? String(res.status), method);
128
+ return j;
129
+ }
130
+ async start(onMessage) {
131
+ this.stopped = false;
132
+ this.abort = new AbortController();
133
+ this.onMessage = onMessage;
134
+ clearInterval(this.pinger);
135
+ this.pinger = setInterval(() => {
136
+ const ws = this.ws;
137
+ if (!ws || ws.readyState !== WebSocket.OPEN)
138
+ return;
139
+ if (!this.alive) {
140
+ ws.terminate();
141
+ return;
142
+ } // `close` follows and reconnects
143
+ this.alive = false;
144
+ ws.ping();
145
+ }, PING_EVERY_MS);
146
+ this.pinger.unref?.();
147
+ try {
148
+ this.me = await this.api('auth.test');
149
+ await this.connect();
150
+ }
151
+ catch (e) {
152
+ this.state = { state: 'error', detail: e instanceof SlackError ? readable(e) : e.message };
153
+ if (!(e instanceof SlackError && AUTH_ERRORS.has(e.code)))
154
+ this.reconnect(); // a network problem at start: keep trying, like Telegram's polling does
155
+ throw new Error(this.state.detail);
156
+ }
157
+ }
158
+ async stop() {
159
+ this.stopped = true;
160
+ this.abort.abort();
161
+ clearInterval(this.pinger);
162
+ const ws = this.ws;
163
+ this.ws = null;
164
+ ws?.terminate();
165
+ this.onMessage = null;
166
+ this.state = { state: 'stopped' };
167
+ }
168
+ status() { return this.state; }
169
+ connected() { return { state: 'connected', detail: `@${this.me?.user} · ${this.me?.team}`, lastEventAt: this.state.lastEventAt }; }
170
+ /** Opens a socket (`apps.connections.open` gives its URL) and resolves on Slack's `hello`. The previous socket, if any, is closed only then, so a refresh loses nothing. */
171
+ async connect() {
172
+ const { url } = await this.api('apps.connections.open', {}, this.conn.config.app_token);
173
+ if (this.stopped)
174
+ return;
175
+ const ws = new WebSocket(url);
176
+ await new Promise((resolve, reject) => {
177
+ let hello = false;
178
+ const timer = setTimeout(() => { ws.terminate(); reject(new Error('Slack did not open the socket in time')); }, HELLO_TIMEOUT_MS);
179
+ ws.on('message', (data) => {
180
+ let f;
181
+ try {
182
+ f = JSON.parse(String(data));
183
+ }
184
+ catch {
185
+ return;
186
+ }
187
+ if (f.type === 'hello') {
188
+ hello = true;
189
+ clearTimeout(timer);
190
+ const old = this.ws;
191
+ this.ws = ws;
192
+ this.alive = true;
193
+ this.state = this.connected();
194
+ if (old && old !== ws)
195
+ old.close();
196
+ resolve();
197
+ return;
198
+ }
199
+ this.frame(ws, f);
200
+ });
201
+ ws.on('pong', () => { this.alive = true; });
202
+ ws.on('error', (e) => { if (!hello) {
203
+ clearTimeout(timer);
204
+ reject(e);
205
+ }
206
+ else
207
+ console.warn(`[slack] ${this.conn.name}: socket error: ${e.message}`); });
208
+ ws.on('close', () => {
209
+ if (!hello) {
210
+ clearTimeout(timer);
211
+ reject(new Error('Slack closed the socket before it opened'));
212
+ return;
213
+ }
214
+ if (this.ws === ws) {
215
+ this.ws = null;
216
+ this.reconnect();
217
+ } // a socket replaced by a newer one closes quietly
218
+ });
219
+ });
220
+ if (this.stopped)
221
+ ws.terminate();
222
+ }
223
+ /** Opens a new socket, waiting longer after each failure. A rejected token stops it: retrying will not fix that. */
224
+ reconnect() {
225
+ if (this.stopped || this.reconnecting)
226
+ return;
227
+ this.reconnecting = true;
228
+ void (async () => {
229
+ let wait = 1000;
230
+ while (!this.stopped) {
231
+ try {
232
+ if (!this.me)
233
+ this.me = await this.api('auth.test');
234
+ await this.connect();
235
+ break;
236
+ }
237
+ catch (e) {
238
+ if (this.stopped)
239
+ break;
240
+ this.state = { state: 'error', detail: e instanceof SlackError ? readable(e) : e.message };
241
+ if (e instanceof SlackError && AUTH_ERRORS.has(e.code))
242
+ break;
243
+ await sleep(e instanceof SlackError && e.retryAfter ? e.retryAfter * 1000 : wait);
244
+ wait = Math.min(wait * 2, 30_000);
245
+ }
246
+ }
247
+ this.reconnecting = false;
248
+ })();
249
+ }
250
+ /** One frame of the socket. Acknowledged first, so a slow turn never makes Slack send it again. */
251
+ frame(ws, f) {
252
+ if (f.envelope_id)
253
+ ws.send(JSON.stringify({ envelope_id: f.envelope_id }));
254
+ if (f.type === 'disconnect') {
255
+ if (this.ws === ws)
256
+ this.reconnect();
257
+ return;
258
+ } // Slack refreshes the socket every few hours
259
+ const parsed = f.type === 'events_api' ? () => this.parseEvent(f.payload?.event) : f.type === 'slash_commands' ? () => this.parseCommand(f.payload) : null;
260
+ if (!parsed)
261
+ return;
262
+ this.order = this.order.then(async () => {
263
+ const m = await parsed();
264
+ if (!m || !this.onMessage)
265
+ return;
266
+ this.state = { ...this.state, lastEventAt: Date.now() };
267
+ void this.onMessage(m).catch((e) => console.error('[slack] handler failed:', e));
268
+ }).catch((e) => console.error(`[slack] ${this.conn.name}: could not read an event:`, e));
269
+ }
270
+ async userName(id) {
271
+ if (!id)
272
+ return '';
273
+ const known = this.names.get(id);
274
+ if (known)
275
+ return known;
276
+ try {
277
+ const { user } = await this.api('users.info', { user: id });
278
+ const name = user?.profile?.display_name || user?.real_name || user?.name || id;
279
+ this.names.set(id, name);
280
+ return name;
281
+ }
282
+ catch {
283
+ return id;
284
+ }
285
+ }
286
+ async placeOf(channel, type) {
287
+ if (type === 'im')
288
+ return 'DM';
289
+ const known = this.places.get(channel);
290
+ if (known)
291
+ return known;
292
+ let place = type === 'mpim' ? 'group DM' : `#${channel}`;
293
+ try {
294
+ const { channel: c } = await this.api('conversations.info', { channel });
295
+ if (c?.name && type !== 'mpim')
296
+ place = `#${c.name}`;
297
+ }
298
+ catch { /* without channels:read the id has to do */ }
299
+ this.places.set(channel, place);
300
+ return place;
301
+ }
302
+ /** Slack's markup the agent should read as plain text: `<@U1>` → `@name`, `<#C1|deploys>` → `#deploys`, `<url|label>` → `label (url)`. */
303
+ async plain(text) {
304
+ const ids = [...new Set([...text.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].map((x) => x[1]))];
305
+ const named = new Map(await Promise.all(ids.map(async (id) => [id, await this.userName(id)])));
306
+ return text
307
+ .replace(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${named.get(id) ?? id}`)
308
+ .replace(/<#[CG][A-Z0-9]+\|([^>]*)>/g, '#$1').replace(/<#([CG][A-Z0-9]+)>/g, '#$1')
309
+ .replace(/<!(here|channel|everyone)(?:\|[^>]*)?>/g, '@$1').replace(/<!subteam\^[A-Z0-9]+\|([^>]*)>/g, '$1')
310
+ .replace(/<((?:https?|mailto):[^|>]+)\|([^>]+)>/g, '$2 ($1)').replace(/<((?:https?|mailto):[^>]+)>/g, '$1')
311
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
312
+ }
313
+ /** A Slack message event → what the router understands; null when it is not for us. */
314
+ async parseEvent(ev) {
315
+ if (!ev || !this.me || (ev.type !== 'message' && ev.type !== 'app_mention'))
316
+ return null;
317
+ if (ev.bot_id || ev.user === this.me.user_id || !ev.user || ev.hidden)
318
+ return null;
319
+ if (ev.subtype && ev.subtype !== 'file_share' && ev.subtype !== 'thread_broadcast')
320
+ return null; // edits, deletions, joins…
321
+ const channel = String(ev.channel ?? '');
322
+ const type = ev.channel_type ?? (channel.startsWith('D') ? 'im' : 'channel');
323
+ const dm = type === 'im';
324
+ const raw = ev.text ?? '';
325
+ const self = new RegExp(`<@${this.me.user_id}(?:\\|[^>]*)?>`, 'g');
326
+ const mentioned = ev.type === 'app_mention' || self.test(raw);
327
+ const attachments = attachmentsOf(ev);
328
+ const text = (await this.plain(raw.replace(self, ''))).trim();
329
+ if (!text && !attachments.length)
330
+ return null;
331
+ // One conversation is one Slack thread: a message in the timeline is the root of its own thread.
332
+ const root = String(ev.thread_ts ?? ev.ts);
333
+ const key = dm ? channel : `${channel}:${root}`;
334
+ const inThread = !!ev.thread_ts && ev.thread_ts !== ev.ts;
335
+ const cmd = /^\/(\w+)(?:\s+([\s\S]*))?$/.exec(text);
336
+ const ownThread = inThread && (ev.parent_user_id === this.me.user_id || threads.answered(this.conn.id, key));
337
+ const addressed = dm || mentioned || !!cmd || ownThread;
338
+ // Meant for another person of the channel: an @mention of somebody else (and not of the agent).
339
+ let directedAt;
340
+ if (!dm && !mentioned) {
341
+ const other = [...raw.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].map((x) => x[1]).find((id) => id !== ev.user && id !== this.me.user_id);
342
+ if (other)
343
+ directedAt = await this.userName(other);
344
+ }
345
+ return {
346
+ externalId: String(ev.ts),
347
+ externalKey: key,
348
+ userId: String(ev.user),
349
+ userName: await this.userName(String(ev.user)),
350
+ text,
351
+ target: dm ? { chat: channel } : { chat: channel, thread: root },
352
+ place: await this.placeOf(channel, type),
353
+ command: cmd ? { name: cmd[1].toLowerCase(), args: (cmd[2] ?? '').trim() } : undefined,
354
+ group: !dm,
355
+ addressed,
356
+ ...(directedAt ? { directedAt } : {}),
357
+ ...(attachments.length ? { attachments } : {}),
358
+ };
359
+ }
360
+ /** A slash command. Slack sends no thread with it, so the answer goes to the channel (or the DM) it was typed in. */
361
+ async parseCommand(p) {
362
+ if (!p?.command || !p.channel_id || !p.user_id)
363
+ return null;
364
+ const channel = String(p.channel_id), dm = channel.startsWith('D');
365
+ const type = dm ? 'im' : p.channel_name === 'mpdm' || String(p.channel_name ?? '').startsWith('mpdm-') ? 'mpim' : 'channel';
366
+ const command = slashToCommand(String(p.command), String(p.text ?? ''));
367
+ return {
368
+ externalId: `cmd-${p.trigger_id ?? Date.now()}`,
369
+ externalKey: channel,
370
+ userId: String(p.user_id),
371
+ userName: await this.userName(String(p.user_id)),
372
+ text: `/${command.name}${command.args ? ` ${command.args}` : ''}`,
373
+ target: { chat: channel },
374
+ place: dm ? 'DM' : p.channel_name && p.channel_name !== 'privategroup' && type === 'channel' ? `#${p.channel_name}` : await this.placeOf(channel, type),
375
+ command,
376
+ group: !dm,
377
+ addressed: true,
378
+ };
379
+ }
380
+ /** Keeps one channel's messages spaced out and honours Slack's `Retry-After` when it pushes back. */
381
+ async withGap(chat, fn) {
382
+ for (let attempt = 0;; attempt++) {
383
+ const gap = MIN_GAP_MS - (Date.now() - (this.lastSend.get(chat) ?? 0));
384
+ if (gap > 0)
385
+ await sleep(gap);
386
+ this.lastSend.set(chat, Date.now());
387
+ try {
388
+ return await fn();
389
+ }
390
+ catch (e) {
391
+ if (e instanceof SlackError && e.code === 'ratelimited' && attempt < 3) {
392
+ await sleep(Math.min(e.retryAfter ?? 1, 30) * 1000);
393
+ continue;
394
+ }
395
+ throw e instanceof SlackError ? new Error(readable(e)) : e;
396
+ }
397
+ }
398
+ }
399
+ async send(to, text) {
400
+ let last = '';
401
+ for (const part of splitMessage(text, MAX_PART)) {
402
+ const sent = await this.withGap(to.chat, () => this.api('chat.postMessage', { channel: to.chat, thread_ts: to.thread, text: mdToSlackMrkdwn(part), unfurl_links: false, unfurl_media: false }));
403
+ last = String(sent.ts);
404
+ }
405
+ return { externalId: last };
406
+ }
407
+ /** Slack has no «typing…» for bots, and a reaction on every message would be noise: nothing is shown while a turn runs. */
408
+ async busy() { }
409
+ /** Scheduled messages and notices go to the channel's timeline, not into the thread they were asked from. A DM has no threads. */
410
+ proactive(to) { return { chat: to.chat }; }
411
+ /** `<@U123>` notifies the person. */
412
+ mention(u) { return `<@${u.id}>`; }
413
+ /** What the agent may react with on Slack; each emoji goes to `reactions.add` by its name. `:name:` is accepted too. */
414
+ reactions = SLACK_REACTIONS;
415
+ async react(to, ref, emoji) {
416
+ const wanted = emoji.replace(/️/g, '').trim();
417
+ const byName = /^:?([a-z0-9_+-]+):?$/.exec(wanted)?.[1];
418
+ const name = byName && KNOWN_NAMES.has(byName) ? byName : NAME_OF.get(wanted);
419
+ if (!name)
420
+ throw new Error(`“${emoji}” is not one of the reactions hive-am uses on Slack. The ones you can use: ${SLACK_REACTIONS.join(' ')}`);
421
+ if (!ref || ref.startsWith('sched-') || ref.startsWith('cmd-'))
422
+ throw new Error('There is no message to react to.');
423
+ try {
424
+ await this.api('reactions.add', { channel: to.chat, timestamp: ref, name });
425
+ }
426
+ catch (e) {
427
+ if (e instanceof SlackError && e.code === 'already_reacted')
428
+ return;
429
+ if (e instanceof SlackError && e.code === 'invalid_name')
430
+ throw new Error(`Slack does not know the emoji “${emoji}”. Try another one, or answer with text.`);
431
+ throw e instanceof SlackError ? new Error(readable(e)) : e;
432
+ }
433
+ }
434
+ /** Slack's upload in three steps: ask for an upload URL, send the bytes there, then share the file in the channel (and thread) with the caption. */
435
+ async sendFile(to, file, caption) {
436
+ const bytes = readFileSync(file.path);
437
+ try {
438
+ const up = await this.api('files.getUploadURLExternal', { filename: file.name, length: bytes.length });
439
+ const res = await fetch(up.upload_url, { method: 'POST', headers: { 'content-type': file.mime || 'application/octet-stream' }, body: bytes, signal: within(UPLOAD_TIMEOUT_MS, this.abort.signal) });
440
+ if (!res.ok)
441
+ throw new Error(`Slack did not take the file (${res.status})`);
442
+ await this.withGap(to.chat, () => this.api('files.completeUploadExternal', {
443
+ files: [{ id: up.file_id, title: file.name }], channel_id: to.chat, thread_ts: to.thread, initial_comment: caption ? mdToSlackMrkdwn(caption) : undefined,
444
+ }));
445
+ return { externalId: up.file_id };
446
+ }
447
+ catch (e) {
448
+ throw e instanceof SlackError ? new Error(readable(e)) : timedOut(e) ? new Error('Slack did not take the file in time') : e;
449
+ }
450
+ }
451
+ /** Files are private: the URL needs the bot token (and the `files:read` scope; without it Slack answers with a login page). */
452
+ async download(att, dest) {
453
+ const res = await fetch(att.id, { headers: { authorization: `Bearer ${this.conn.config.bot_token}` }, signal: within(DOWNLOAD_TIMEOUT_MS, this.abort.signal) });
454
+ if (!res.ok)
455
+ throw new Error(`download failed (${res.status})`);
456
+ if (/text\/html/i.test(res.headers.get('content-type') ?? '') && !/html/i.test(att.mime ?? ''))
457
+ throw new Error('Slack answered with a login page: the app needs the files:read scope');
458
+ if (Number(res.headers.get('content-length') ?? 0) > MAX_FILE_BYTES)
459
+ throw new Error('too big (limit 20 MB)');
460
+ const buf = Buffer.from(await res.arrayBuffer());
461
+ if (buf.length > MAX_FILE_BYTES)
462
+ throw new Error('too big (limit 20 MB)');
463
+ writeFileSync(dest, buf);
464
+ return buf.length;
465
+ }
466
+ /** Checks the bot token and greets each allowed person by DM. */
467
+ async test(userIds) {
468
+ let me;
469
+ try {
470
+ me = await this.api('auth.test');
471
+ }
472
+ catch (e) {
473
+ throw e instanceof SlackError ? new Error(readable(e)) : e;
474
+ }
475
+ const failed = [];
476
+ for (const id of userIds) {
477
+ try {
478
+ const { channel } = await this.api('conversations.open', { users: id });
479
+ await this.send({ chat: channel.id }, `✅ hive-am is connected as @${me.user} in ${me.team}.`);
480
+ }
481
+ catch (e) {
482
+ failed.push(`${id}: ${e instanceof SlackError ? readable(e) : e.message}`);
483
+ }
484
+ }
485
+ const ok = userIds.length - failed.length;
486
+ return `@${me.user} · ${me.team} · greeted ${ok} of ${userIds.length}${failed.length ? ` (${failed.join('; ')})` : ''}`;
487
+ }
488
+ }
@@ -46,6 +46,8 @@ export const threads = {
46
46
  .run(id, connectionId, externalKey, info.title, JSON.stringify(info.target), info.user, t, t);
47
47
  return threads.get(id);
48
48
  },
49
+ /** The agent already wrote in this thread (Slack: people who keep talking in a thread it answered are talking to it). */
50
+ answered: (connectionId, externalKey) => !!db.prepare("SELECT 1 FROM threads t JOIN thread_messages m ON m.thread_id=t.id WHERE t.connection_id=? AND t.external_key=? AND m.direction='out' LIMIT 1").get(connectionId, externalKey),
49
51
  setMuted: (id, muted) => { db.prepare('UPDATE threads SET muted=? WHERE id=?').run(muted ? 1 : 0, id); },
50
52
  markSeen: (id, upTo) => { db.prepare('UPDATE threads SET seen_id=MAX(seen_id, ?) WHERE id=?').run(upTo, id); },
51
53
  };
@@ -257,6 +257,18 @@ CREATE TABLE IF NOT EXISTS object_agents (
257
257
  addColumn(db, 'agent_types', "effort TEXT NOT NULL DEFAULT ''");
258
258
  },
259
259
  },
260
+ {
261
+ version: 9, name: 'dispatch-background',
262
+ up(db) {
263
+ // A delegation can run in the background: the orchestrator is told the result where it asked (`origin`, JSON of the chat thread, NULL for
264
+ // the web conversation) and `notified_at` records that the message was handed over, `read_at` that it read the result itself.
265
+ // Every existing row was a synchronous one, whose answer went back on the request: none of them is ever announced.
266
+ addColumn(db, 'dispatches', "mode TEXT NOT NULL DEFAULT 'sync'");
267
+ addColumn(db, 'dispatches', 'origin TEXT');
268
+ addColumn(db, 'dispatches', 'notified_at INTEGER');
269
+ addColumn(db, 'dispatches', 'read_at INTEGER');
270
+ },
271
+ },
260
272
  ];
261
273
  export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
262
274
  /** Brings a database up to the current schema. Returns the versions it applied (empty when it was already current). */
package/server/dist/db.js CHANGED
@@ -245,16 +245,28 @@ export const agents = {
245
245
  allSessions: () => q(`SELECT s.*, a.name agent_name, a.role agent_role, f.name from_name FROM agent_sessions s JOIN agents a ON a.id=s.agent_id LEFT JOIN agents f ON f.id=s.from_id ORDER BY s.last_seen DESC`).all(),
246
246
  };
247
247
  // ---- dispatch log ----
248
+ // A delegation is `queued` until the worker's turn starts (it waits behind the worker's other turns), then `running`, then `done`, `failed`
249
+ // or `interrupted` (the server stopped while it was queued or running).
248
250
  export const dispatches = {
249
- start(from, to, task) {
251
+ start(from, to, task, mode = 'sync', origin = null) {
250
252
  const id = uid();
251
- q('INSERT INTO dispatches (id, from_id, to_id, task, status, result, created_at, finished_at) VALUES (?,?,?,?,?,NULL,?,NULL)').run(id, from, to, task, 'running', now());
253
+ q('INSERT INTO dispatches (id, from_id, to_id, task, status, result, created_at, finished_at, mode, origin) VALUES (?,?,?,?,?,NULL,?,NULL,?,?)').run(id, from, to, task, 'queued', now(), mode, origin);
252
254
  return id;
253
255
  },
256
+ get: (id) => q('SELECT * FROM dispatches WHERE id=?').get(id),
257
+ setRunning: (id) => q("UPDATE dispatches SET status='running' WHERE id=? AND status='queued'").run(id),
254
258
  setSession: (id, sessionId) => q('UPDATE dispatches SET session_id=? WHERE id=?').run(sessionId, id),
255
259
  finish: (id, status, result) => q('UPDATE dispatches SET status=?, result=?, finished_at=? WHERE id=?').run(status, result.slice(0, 20000), now(), id),
260
+ markNotified: (id) => q('UPDATE dispatches SET notified_at=? WHERE id=? AND notified_at IS NULL').run(now(), id),
261
+ markRead: (id) => q('UPDATE dispatches SET read_at=? WHERE id=? AND read_at IS NULL').run(now(), id),
262
+ /** Background tasks of an orchestrator that have not finished yet. */
263
+ unfinished: (from) => q("SELECT COUNT(*) AS n FROM dispatches WHERE from_id=? AND mode='background' AND status IN ('queued','running')").get(from).n,
264
+ /** Finished background tasks whose result was not handed to the orchestrator yet. */
265
+ undelivered: () => q("SELECT * FROM dispatches WHERE mode='background' AND status NOT IN ('queued','running') AND notified_at IS NULL ORDER BY created_at").all(),
266
+ /** At startup nothing is running any more: what was queued or running is interrupted (a task is never resumed). */
267
+ interruptLeftovers: () => q("UPDATE dispatches SET status='interrupted', finished_at=? WHERE status IN ('queued','running')").run(now()).changes,
256
268
  recent: (limit = 50) => q('SELECT * FROM dispatches ORDER BY created_at DESC LIMIT ?').all(limit),
257
269
  };
258
270
  // Agents left 'running' by a crash/blackout are simply idle again: their session is intact on disk.
259
271
  q("UPDATE agents SET status='idle' WHERE status='running'").run();
260
- q("UPDATE dispatches SET status='interrupted' WHERE status='running'").run();
272
+ dispatches.interruptLeftovers();