clankie 0.2.1 → 0.2.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 (41) hide show
  1. package/README.md +29 -13
  2. package/dist/cli.js +301851 -0
  3. package/dist/koffi-216xhpes.node +0 -0
  4. package/dist/koffi-2erktc37.node +0 -0
  5. package/dist/koffi-2rrez93a.node +0 -0
  6. package/dist/koffi-2wv0r22g.node +0 -0
  7. package/dist/koffi-3kae4xj3.node +0 -0
  8. package/dist/koffi-3rkr2zqv.node +0 -0
  9. package/dist/koffi-abxfktv9.node +0 -0
  10. package/dist/koffi-c67c0c5b.node +0 -0
  11. package/dist/koffi-cnf0q0dx.node +0 -0
  12. package/dist/koffi-df38sqz5.node +0 -0
  13. package/dist/koffi-gfbqb3a0.node +0 -0
  14. package/dist/koffi-kjemmmem.node +0 -0
  15. package/dist/koffi-kkrfq9yv.node +0 -0
  16. package/dist/koffi-mzaqwwqy.node +0 -0
  17. package/dist/koffi-q49fgkeq.node +0 -0
  18. package/dist/koffi-q54bk8bf.node +0 -0
  19. package/dist/koffi-x1790w0j.node +0 -0
  20. package/dist/koffi-yxvjwcj6.node +0 -0
  21. package/package.json +17 -7
  22. package/web-ui-dist/_shell.html +2 -2
  23. package/web-ui-dist/assets/{card-kSKmECr1.js → card-BUP-xovx.js} +1 -1
  24. package/web-ui-dist/assets/extensions-DC620Nmx.js +1 -0
  25. package/web-ui-dist/assets/{index-CXJ3n5rE.js → index-DurjG9O_.js} +1 -1
  26. package/web-ui-dist/assets/{loader-circle-C5ib508E.js → loader-circle-DbOtKfCA.js} +1 -1
  27. package/web-ui-dist/assets/{main-cBOaKYCP.js → main-B2sRcuyZ.js} +8 -8
  28. package/web-ui-dist/assets/{sessions._sessionId-BIeINoSQ.js → sessions._sessionId-BJazw9EJ.js} +1 -1
  29. package/web-ui-dist/assets/{settings-CO37Obvo.js → settings-Bv8oeIho.js} +1 -1
  30. package/web-ui-dist/assets/styles-D2oHO1JL.css +1 -0
  31. package/src/agent.ts +0 -107
  32. package/src/channels/channel.ts +0 -57
  33. package/src/channels/slack.ts +0 -374
  34. package/src/channels/web.ts +0 -1362
  35. package/src/cli.ts +0 -505
  36. package/src/config.ts +0 -257
  37. package/src/daemon.ts +0 -380
  38. package/src/service.ts +0 -372
  39. package/src/sessions.ts +0 -251
  40. package/web-ui-dist/assets/extensions-CFPfugfg.js +0 -1
  41. package/web-ui-dist/assets/styles-BQfA8H-l.css +0 -1
@@ -1,374 +0,0 @@
1
- /**
2
- * Slack channel — uses Socket Mode (WebSocket-based, no public URL needed).
3
- *
4
- * Requires:
5
- * - Slack app with Socket Mode enabled
6
- * - App token (xapp-...) for Socket Mode connection
7
- * - Bot token (xoxb-...) for API calls
8
- * - Bot scopes: app_mentions:read, chat:write, files:read, im:history, mpim:history,
9
- * channels:history, channels:read, groups:history, groups:read
10
- * - Event subscriptions: app_mention, message.channels, message.groups, message.im, message.mpim
11
- *
12
- * Responds to:
13
- * - @mentions in channels and private channels (starts a conversation thread)
14
- * - Messages in threads where bot was @mentioned (continues conversation)
15
- * - Direct messages (1:1 and multi-party DMs)
16
- *
17
- * Features:
18
- * - File attachments (downloads from Slack, converts to base64)
19
- * - Thread persistence (survives daemon restarts, 7-day TTL)
20
- * - Channel allowlisting (optional)
21
- * - User allowlisting (required)
22
- * - Link unfurling disabled (keeps responses clean)
23
- */
24
-
25
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
- import { homedir } from "node:os";
27
- import { join } from "node:path";
28
- import { SocketModeClient } from "@slack/socket-mode";
29
- import { WebClient } from "@slack/web-api";
30
- import type { Attachment, Channel, InboundMessage, MessageHandler, SendOptions } from "./channel.ts";
31
-
32
- const SLACK_MAX_LENGTH = 4000; // Slack's actual limit is ~40k, but chunk conservatively
33
- const ACTIVE_THREADS_FILE = join(homedir(), ".clankie", "slack-active-threads.json");
34
- const THREAD_TTL_DAYS = 7; // Threads older than this are cleaned up
35
-
36
- export interface SlackChannelOptions {
37
- /** App token from Slack app settings (xapp-...) */
38
- appToken: string;
39
- /** Bot token from Slack app settings (xoxb-...) */
40
- botToken: string;
41
- /** Allowed Slack user IDs. Empty = deny all. */
42
- allowedUsers: string[];
43
- /** Allowed Slack channel IDs. Empty = allow all. */
44
- allowedChannelIds?: string[];
45
- }
46
-
47
- export class SlackChannel implements Channel {
48
- readonly name = "slack";
49
- private socketClient: SocketModeClient;
50
- private webClient: WebClient;
51
- private allowedUsers: Set<string>;
52
- private allowedChannelIds: Set<string> | null;
53
- private handler: MessageHandler | undefined;
54
- private botUserId: string | null = null;
55
- /** Threads where bot has been @mentioned - Map<threadId, timestamp> for TTL cleanup */
56
- private activeThreads: Map<string, number> = new Map();
57
-
58
- constructor(private options: SlackChannelOptions) {
59
- this.socketClient = new SocketModeClient({
60
- appToken: options.appToken,
61
- // biome-ignore lint/suspicious/noExplicitAny: Slack SDK logLevel type is not exported
62
- logLevel: "ERROR" as any, // Suppress noisy internal logging
63
- });
64
- this.webClient = new WebClient(options.botToken);
65
- this.allowedUsers = new Set(options.allowedUsers);
66
- // null = allow all channels; Set = filter by channel ID
67
- this.allowedChannelIds = options.allowedChannelIds?.length ? new Set(options.allowedChannelIds) : null;
68
- }
69
-
70
- async start(handler: MessageHandler): Promise<void> {
71
- this.handler = handler;
72
-
73
- // Load persisted active threads
74
- this.loadActiveThreads();
75
-
76
- // Get bot user ID
77
- const auth = await this.webClient.auth.test();
78
- this.botUserId = auth.user_id as string;
79
-
80
- this.setupEventHandlers();
81
- await this.socketClient.start();
82
-
83
- console.log(`[slack] Connected as ${auth.user} (${this.botUserId})`);
84
- }
85
-
86
- async send(chatId: string, text: string, options?: SendOptions): Promise<void> {
87
- if (text.length <= SLACK_MAX_LENGTH) {
88
- await this.webClient.chat.postMessage({
89
- channel: chatId,
90
- text,
91
- thread_ts: options?.threadId,
92
- unfurl_links: false,
93
- unfurl_media: false,
94
- });
95
- return;
96
- }
97
-
98
- // Split long messages
99
- const chunks = this.splitMessage(text, SLACK_MAX_LENGTH);
100
- for (const chunk of chunks) {
101
- await this.webClient.chat.postMessage({
102
- channel: chatId,
103
- text: chunk,
104
- thread_ts: options?.threadId,
105
- unfurl_links: false,
106
- unfurl_media: false,
107
- });
108
- }
109
- }
110
-
111
- async stop(): Promise<void> {
112
- await this.socketClient.disconnect();
113
- console.log("[slack] Disconnected");
114
- }
115
-
116
- // ─── Private helpers ──────────────────────────────────────────────────
117
-
118
- /** Check if a channel is allowed (null allowedChannelIds = allow all) */
119
- private isChannelAllowed(channelId: string): boolean {
120
- return this.allowedChannelIds === null || this.allowedChannelIds.has(channelId);
121
- }
122
-
123
- /** Load active threads from disk (with TTL cleanup) */
124
- private loadActiveThreads(): void {
125
- if (!existsSync(ACTIVE_THREADS_FILE)) return;
126
-
127
- try {
128
- const raw = readFileSync(ACTIVE_THREADS_FILE, "utf-8");
129
- const data = JSON.parse(raw) as Record<string, number>;
130
- const now = Date.now();
131
- const ttlMs = THREAD_TTL_DAYS * 24 * 60 * 60 * 1000;
132
-
133
- let loaded = 0;
134
- let expired = 0;
135
-
136
- for (const [threadId, timestamp] of Object.entries(data)) {
137
- if (now - timestamp > ttlMs) {
138
- expired++;
139
- continue;
140
- }
141
- this.activeThreads.set(threadId, timestamp);
142
- loaded++;
143
- }
144
-
145
- if (loaded > 0) {
146
- console.log(`[slack] Loaded ${loaded} active thread(s) from disk${expired > 0 ? ` (${expired} expired)` : ""}`);
147
- }
148
- } catch (err) {
149
- console.warn(`[slack] Failed to load active threads: ${err instanceof Error ? err.message : String(err)}`);
150
- }
151
- }
152
-
153
- /** Save active threads to disk */
154
- private saveActiveThreads(): void {
155
- try {
156
- const dir = join(homedir(), ".clankie");
157
- if (!existsSync(dir)) {
158
- mkdirSync(dir, { recursive: true, mode: 0o700 });
159
- }
160
-
161
- const data: Record<string, number> = {};
162
- for (const [threadId, timestamp] of this.activeThreads.entries()) {
163
- data[threadId] = timestamp;
164
- }
165
-
166
- writeFileSync(ACTIVE_THREADS_FILE, JSON.stringify(data, null, 2), "utf-8");
167
- } catch (err) {
168
- console.warn(`[slack] Failed to save active threads: ${err instanceof Error ? err.message : String(err)}`);
169
- }
170
- }
171
-
172
- private setupEventHandlers(): void {
173
- // Handle @mentions in channels
174
- this.socketClient.on("app_mention", async ({ event, ack }) => {
175
- try {
176
- await ack();
177
-
178
- const e = event as {
179
- text: string;
180
- channel: string;
181
- user: string;
182
- ts: string;
183
- thread_ts?: string;
184
- files?: Array<{
185
- id: string;
186
- name?: string;
187
- mimetype?: string;
188
- url_private_download?: string;
189
- }>;
190
- };
191
-
192
- // Check channel allowlist
193
- if (!this.isChannelAllowed(e.channel)) {
194
- console.log(`[slack] Ignoring mention in disallowed channel: ${e.channel}`);
195
- return;
196
- }
197
-
198
- if (!this.allowedUsers.has(e.user)) {
199
- console.log(`[slack] Ignoring mention from unauthorized user: ${e.user}`);
200
- return;
201
- }
202
-
203
- // Track this thread as active for future conversation
204
- const threadId = e.thread_ts || e.ts;
205
- this.activeThreads.set(threadId, Date.now());
206
- this.saveActiveThreads();
207
- console.log(`[slack] Thread ${threadId} is now active (${this.activeThreads.size} total)`);
208
-
209
- // Strip bot mention from text
210
- const text = e.text.replace(/<@[A-Z0-9]+>/gi, "").trim();
211
-
212
- const message: InboundMessage = {
213
- id: e.ts,
214
- channel: this.name,
215
- senderId: e.user,
216
- chatId: e.channel,
217
- threadId: e.thread_ts,
218
- text,
219
- timestamp: parseFloat(e.ts) * 1000,
220
- };
221
-
222
- // Download attachments
223
- if (e.files && e.files.length > 0) {
224
- message.attachments = await this.downloadFiles(e.files);
225
- }
226
-
227
- await this.handler?.(message);
228
- } catch (err) {
229
- console.error("[slack] Error in app_mention handler:", err);
230
- }
231
- });
232
-
233
- // Handle direct messages and thread replies
234
- this.socketClient.on("message", async ({ event, ack }) => {
235
- try {
236
- await ack();
237
-
238
- const e = event as {
239
- text?: string;
240
- channel: string;
241
- user?: string;
242
- ts: string;
243
- channel_type?: string;
244
- subtype?: string;
245
- bot_id?: string;
246
- thread_ts?: string;
247
- files?: Array<{
248
- id: string;
249
- name?: string;
250
- mimetype?: string;
251
- url_private_download?: string;
252
- }>;
253
- };
254
-
255
- // Skip bot messages, message edits, etc.
256
- if (e.bot_id || !e.user || e.user === this.botUserId) return;
257
- if (e.subtype !== undefined && e.subtype !== "file_share") return;
258
- if (!e.text && (!e.files || e.files.length === 0)) return;
259
-
260
- // Check channel allowlist
261
- if (!this.isChannelAllowed(e.channel)) return;
262
-
263
- const isDM = e.channel_type === "im";
264
- const isMpim = e.channel_type === "mpim"; // Multi-party DM
265
- const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
266
- const isInActiveThread = e.thread_ts && this.activeThreads.has(e.thread_ts);
267
-
268
- // Skip channel/group @mentions (handled by app_mention event)
269
- // Note: mpim (multi-party DMs) don't fire app_mention, so we must NOT skip those
270
- if (!isDM && !isMpim && isBotMention) return;
271
-
272
- // Only process: DMs, multi-party DMs, OR messages in active threads
273
- if (!isDM && !isMpim && !isInActiveThread) return;
274
-
275
- if (!this.allowedUsers.has(e.user)) {
276
- console.log(`[slack] Ignoring message from unauthorized user: ${e.user}`);
277
- return;
278
- }
279
-
280
- const message: InboundMessage = {
281
- id: e.ts,
282
- channel: this.name,
283
- senderId: e.user,
284
- chatId: e.channel,
285
- threadId: e.thread_ts,
286
- text: e.text || "",
287
- timestamp: parseFloat(e.ts) * 1000,
288
- };
289
-
290
- // Download attachments
291
- if (e.files && e.files.length > 0) {
292
- message.attachments = await this.downloadFiles(e.files);
293
- }
294
-
295
- await this.handler?.(message);
296
- } catch (err) {
297
- console.error("[slack] Error in message handler:", err);
298
- }
299
- });
300
- }
301
-
302
- /**
303
- * Download files from Slack and convert to base64 attachments.
304
- */
305
- private async downloadFiles(
306
- files: Array<{
307
- id: string;
308
- name?: string;
309
- mimetype?: string;
310
- url_private_download?: string;
311
- }>,
312
- ): Promise<Attachment[]> {
313
- const attachments: Attachment[] = [];
314
-
315
- for (const file of files) {
316
- const url = file.url_private_download;
317
- if (!url) {
318
- console.warn(`[slack] File ${file.id} has no download URL, skipping`);
319
- continue;
320
- }
321
-
322
- try {
323
- const response = await fetch(url, {
324
- headers: {
325
- Authorization: `Bearer ${this.options.botToken}`,
326
- },
327
- });
328
-
329
- if (!response.ok) {
330
- console.warn(`[slack] Failed to download file ${file.id}: ${response.status} ${response.statusText}`);
331
- continue;
332
- }
333
-
334
- const buffer = Buffer.from(await response.arrayBuffer());
335
- attachments.push({
336
- data: buffer.toString("base64"),
337
- mimeType: file.mimetype || "application/octet-stream",
338
- fileName: file.name || file.id,
339
- });
340
- } catch (err) {
341
- console.error(`[slack] Error downloading file ${file.id}:`, err);
342
- }
343
- }
344
-
345
- return attachments;
346
- }
347
-
348
- /**
349
- * Split a long message into chunks, preferring newline boundaries.
350
- */
351
- private splitMessage(text: string, maxLen: number): string[] {
352
- const chunks: string[] = [];
353
- let remaining = text;
354
-
355
- while (remaining.length > 0) {
356
- if (remaining.length <= maxLen) {
357
- chunks.push(remaining);
358
- break;
359
- }
360
-
361
- // Find last newline within the limit
362
- let splitAt = remaining.lastIndexOf("\n", maxLen);
363
- if (splitAt <= 0) {
364
- // No good newline — hard-split
365
- splitAt = maxLen;
366
- }
367
-
368
- chunks.push(remaining.slice(0, splitAt));
369
- remaining = remaining.slice(splitAt).replace(/^\n/, ""); // trim leading newline
370
- }
371
-
372
- return chunks;
373
- }
374
- }