gibi-bot 1.0.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 (43) hide show
  1. package/.context.json +185 -0
  2. package/.env.example +4 -0
  3. package/DISTRIBUTION.md +55 -0
  4. package/GEMINI.md +20 -0
  5. package/LICENSE +21 -0
  6. package/README.md +192 -0
  7. package/assets/gibi_avatar.png +0 -0
  8. package/conductor/code_styleguides/general.md +23 -0
  9. package/conductor/code_styleguides/ts.md +52 -0
  10. package/conductor/product-guidelines.md +28 -0
  11. package/conductor/product.md +20 -0
  12. package/conductor/setup_state.json +1 -0
  13. package/conductor/tech-stack.md +15 -0
  14. package/conductor/tracks/slack_bot_20260107/metadata.json +8 -0
  15. package/conductor/tracks/slack_bot_20260107/plan.md +26 -0
  16. package/conductor/tracks/slack_bot_20260107/spec.md +18 -0
  17. package/conductor/tracks.md +8 -0
  18. package/conductor/workflow.md +338 -0
  19. package/dist/agents.js +90 -0
  20. package/dist/agents.test.js +65 -0
  21. package/dist/app.js +740 -0
  22. package/dist/config.js +102 -0
  23. package/dist/context.js +146 -0
  24. package/dist/context.test.js +95 -0
  25. package/dist/prompts.js +20 -0
  26. package/jest.config.js +11 -0
  27. package/nodemon.json +10 -0
  28. package/package.json +44 -0
  29. package/src/agents.test.ts +85 -0
  30. package/src/agents.ts +112 -0
  31. package/src/app.d.ts +2 -0
  32. package/src/app.d.ts.map +1 -0
  33. package/src/app.js +55 -0
  34. package/src/app.js.map +1 -0
  35. package/src/app.ts +809 -0
  36. package/src/config.ts +72 -0
  37. package/src/context.test.ts +75 -0
  38. package/src/context.ts +130 -0
  39. package/src/prompts.ts +17 -0
  40. package/test_gemini.js +23 -0
  41. package/test_gemini_approval.js +24 -0
  42. package/test_gemini_write.js +23 -0
  43. package/tsconfig.json +13 -0
package/src/app.ts ADDED
@@ -0,0 +1,809 @@
1
+ #!/usr/bin/env node
2
+ import { App } from '@slack/bolt';
3
+ import { loadConfig, clearConfig } from './config';
4
+ import { ContextManager } from './context';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { spawn } from 'child_process';
8
+ import { PLAN_MODE_PROMPT } from './prompts';
9
+ import { agentRegistry } from './agents';
10
+
11
+
12
+ const AVAILABLE_MODELS = [
13
+ { id: 'gemini-2.5-pro', desc: 'Complex reasoning, coding (Access required)' },
14
+ { id: 'gemini-2.5-flash', desc: 'Fast, lightweight (Access required)' },
15
+ { id: 'gemini-1.5-pro', desc: 'General purpose, good reasoning' },
16
+ { id: 'gemini-1.5-flash', desc: 'High speed, lower latency' },
17
+ { id: 'gemini-1.0-pro', desc: 'Standard model' },
18
+ ];
19
+
20
+
21
+
22
+
23
+ let isRestarting = false;
24
+
25
+ const start = async () => {
26
+ try {
27
+ await loadConfig();
28
+
29
+ let BOT_USER_ID = '';
30
+ let BOT_ID = '';
31
+
32
+ const app = new App({
33
+ token: process.env.SLACK_BOT_TOKEN || '',
34
+ signingSecret: process.env.SLACK_SIGNING_SECRET || '',
35
+ socketMode: true,
36
+ appToken: process.env.SLACK_APP_TOKEN || ''
37
+ });
38
+
39
+ // Fetch Bot User ID on startup
40
+ (async () => {
41
+ try {
42
+ const authResult = await app.client.auth.test();
43
+ BOT_USER_ID = authResult.user_id as string;
44
+ BOT_ID = authResult.bot_id as string;
45
+ } catch (e) {
46
+ console.error('Failed to fetch Bot User ID:', e);
47
+ }
48
+ })();
49
+
50
+ const contextManager = new ContextManager();
51
+
52
+ // Handle bot being added to a channel
53
+ app.event('member_joined_channel', async ({ event, client, say }) => {
54
+ // Use cached ID or fetch if not ready
55
+ if (!BOT_USER_ID) {
56
+ const authResult = await client.auth.test();
57
+ BOT_USER_ID = authResult.user_id as string;
58
+ BOT_ID = authResult.bot_id as string;
59
+ }
60
+
61
+ if (event.user === BOT_USER_ID) {
62
+ await say(`Hello! I'm Gibi. I'm ready to help. Mention me (@Gibi) to start a new thread!`);
63
+ }
64
+ });
65
+
66
+ // Generic message handler for Agent Chat
67
+ // Helper to handle Agent turns
68
+ const processAgentTurn = async (
69
+ contextId: string,
70
+ channelId: string,
71
+ threadTs: string | undefined,
72
+ userText: string | undefined,
73
+ say: any,
74
+ client: any,
75
+ existingMessageTs?: string
76
+ ) => {
77
+ const context = contextManager.getContext(contextId);
78
+
79
+ // Append User Message if provided
80
+ if (userText) {
81
+ context.messages.push({ role: 'user', content: userText });
82
+ contextManager.setContextData(contextId, { messages: context.messages });
83
+ }
84
+
85
+ let processingMsgTs: string | undefined = existingMessageTs;
86
+
87
+ try {
88
+ const agentName = context.data.agent || 'gemini';
89
+
90
+ // Send or Update ephemeral "Thinking..." message
91
+ const thinkingBlocks = [
92
+ {
93
+ type: 'section',
94
+ text: {
95
+ type: 'mrkdwn',
96
+ text: `_Thinking..._ (using \`${agentName}\`)`
97
+ }
98
+ }
99
+ ];
100
+
101
+ if (processingMsgTs) {
102
+ await client.chat.update({
103
+ channel: channelId,
104
+ ts: processingMsgTs,
105
+ text: 'Thinking...',
106
+ blocks: thinkingBlocks
107
+ });
108
+ } else {
109
+ const processingMsg = await say({
110
+ blocks: thinkingBlocks,
111
+ text: 'Thinking...',
112
+ thread_ts: threadTs
113
+ });
114
+ processingMsgTs = processingMsg.ts as string;
115
+ }
116
+
117
+ // Determine Agent
118
+ const agent = agentRegistry[agentName];
119
+
120
+ if (!agent) {
121
+ throw new Error(`Unknown agent: ${agentName}`);
122
+ }
123
+
124
+ const responseText = await agent.run(context.messages, {
125
+ model: context.data.model,
126
+ mode: context.data.mode
127
+ });
128
+
129
+ if (responseText) {
130
+ // Append Assistant Response
131
+ context.messages.push({ role: 'assistant', content: responseText });
132
+ contextManager.setContextData(contextId, { messages: context.messages });
133
+
134
+ const responseBlocks = [
135
+ {
136
+ type: 'section',
137
+ text: {
138
+ type: 'mrkdwn',
139
+ text: responseText
140
+ }
141
+ },
142
+ {
143
+ type: 'context',
144
+ elements: [
145
+ {
146
+ type: 'mrkdwn',
147
+ text: `šŸ¤– *Agent:* \`${agentName}\` | 🧠 *Model:* \`${context.data.model || 'default'}\` | šŸ“‚ *CWD:* \`${context.data.cwd || 'root'}\``
148
+ }
149
+ ]
150
+ },
151
+ {
152
+ type: 'actions',
153
+ elements: [
154
+ {
155
+ type: 'button',
156
+ text: { type: 'plain_text', text: 'šŸ”„ Retry', emoji: true },
157
+ action_id: 'retry_turn'
158
+ },
159
+ {
160
+ type: 'button',
161
+ text: { type: 'plain_text', text: 'ā†©ļø Revert', emoji: true },
162
+ action_id: 'revert_turn'
163
+ }
164
+ ]
165
+ }
166
+ ];
167
+
168
+ // Update the processing message with the actual response
169
+ if (processingMsgTs) {
170
+ await client.chat.update({
171
+ channel: channelId,
172
+ ts: processingMsgTs,
173
+ text: responseText,
174
+ blocks: responseBlocks
175
+ });
176
+ } else {
177
+ await say({
178
+ text: responseText,
179
+ blocks: responseBlocks,
180
+ thread_ts: threadTs
181
+ });
182
+ }
183
+ } else if (processingMsgTs) {
184
+ // Handle empty response case
185
+ await client.chat.update({
186
+ channel: channelId,
187
+ ts: processingMsgTs,
188
+ text: `Received empty response from ${agentName}.`
189
+ });
190
+ }
191
+
192
+ } catch (error: any) {
193
+ console.error('Error calling Agent:', error);
194
+ const errorMessage = `params: Error calling Agent: ${error.message}`;
195
+
196
+ if (processingMsgTs) {
197
+ await client.chat.update({
198
+ channel: channelId,
199
+ ts: processingMsgTs,
200
+ text: errorMessage
201
+ });
202
+ } else {
203
+ await say({
204
+ text: errorMessage,
205
+ thread_ts: threadTs
206
+ });
207
+ }
208
+ }
209
+ };
210
+
211
+ // Handle generic messages
212
+ app.message(async ({ message, say, client }) => {
213
+ const msg = message as any;
214
+ if (msg.bot_id || msg.subtype === 'bot_message' || !msg.text) return;
215
+
216
+ // Skip if it looks like the cd command
217
+ if (/^cd\s+/.test(msg.text.trim())) return;
218
+
219
+ // Check if this message mentions the bot
220
+ let isMention = false;
221
+ if (BOT_USER_ID && msg.text.includes(`<@${BOT_USER_ID}>`)) isMention = true;
222
+ if (BOT_ID && msg.text.includes(`<@${BOT_ID}>`)) isMention = true;
223
+
224
+ const isDm = msg.channel_type === 'im';
225
+ const isThread = !!msg.thread_ts;
226
+
227
+ const contextId = msg.thread_ts || msg.channel;
228
+
229
+ if (isDm) {
230
+ // Process
231
+ } else if (isThread) {
232
+ if (!isMention && !contextManager.hasContext(contextId)) return;
233
+ } else {
234
+ if (!isMention) return;
235
+ }
236
+
237
+ // Clean up text (remove mention)
238
+ let text = msg.text;
239
+ if (BOT_USER_ID) text = text.replace(new RegExp(`<@${BOT_USER_ID}>`, 'g'), '').trim();
240
+ if (BOT_ID) text = text.replace(new RegExp(`<@${BOT_ID}>`, 'g'), '').trim();
241
+
242
+ const replyThreadTs = msg.thread_ts || msg.ts;
243
+ const targetContextId = msg.thread_ts || msg.ts;
244
+
245
+ await processAgentTurn(targetContextId, msg.channel, replyThreadTs, text, say, client);
246
+ });
247
+
248
+ // Helper for CD command (shared between Slash command and Message)
249
+ const executeChangeDirectory = async (contextId: string, targetPath: string): Promise<any> => {
250
+ const context = contextManager.getContext(contextId);
251
+
252
+ // 1. Determine current context CWD
253
+ const currentCwd = context.data.cwd || process.cwd();
254
+
255
+ // 2. Resolve new path
256
+ let newPath = currentCwd;
257
+ if (targetPath) {
258
+ newPath = path.resolve(currentCwd, targetPath);
259
+ }
260
+
261
+ try {
262
+ // 3. Validate path
263
+ await fs.promises.access(newPath, fs.constants.F_OK);
264
+ const stats = await fs.promises.stat(newPath);
265
+
266
+ if (!stats.isDirectory()) {
267
+ return { text: `āŒ Path is not a directory: \`${newPath}\`` };
268
+ }
269
+
270
+ // 4. Update Context
271
+ contextManager.setContextData(contextId, { cwd: newPath });
272
+
273
+ // 5. List files
274
+ const files = await fs.promises.readdir(newPath, { withFileTypes: true });
275
+
276
+ // Sort: Directories first, then files
277
+ files.sort((a, b) => {
278
+ if (a.isDirectory() && !b.isDirectory()) return -1;
279
+ if (!a.isDirectory() && b.isDirectory()) return 1;
280
+ return a.name.localeCompare(b.name);
281
+ });
282
+
283
+ // Build Blocks
284
+ const blocks: any[] = [
285
+ {
286
+ type: 'section',
287
+ text: {
288
+ type: 'mrkdwn',
289
+ text: `šŸ“‚ *Directory:* \`${newPath}\``
290
+ }
291
+ }
292
+ ];
293
+
294
+ // Add "Up" button if not root
295
+ const parentPath = path.dirname(newPath);
296
+ if (newPath !== parentPath) {
297
+ blocks.push({
298
+ type: 'actions',
299
+ elements: [
300
+ {
301
+ type: 'button',
302
+ text: {
303
+ type: 'plain_text',
304
+ text: 'ā¬†ļø Up one level',
305
+ emoji: true
306
+ },
307
+ value: parentPath,
308
+ action_id: 'navigate_dir'
309
+ }
310
+ ]
311
+ });
312
+ }
313
+
314
+ const dirButtons: any[] = [];
315
+ const fileNames: string[] = [];
316
+
317
+ files.forEach(file => {
318
+ if (file.isDirectory()) {
319
+ if (dirButtons.length < 10) { // Limit buttons to avoid clutter
320
+ dirButtons.push({
321
+ type: 'button',
322
+ text: {
323
+ type: 'plain_text',
324
+ text: `šŸ“ ${file.name}`,
325
+ emoji: true
326
+ },
327
+ value: path.join(newPath, file.name),
328
+ action_id: 'navigate_dir'
329
+ });
330
+ } else {
331
+ fileNames.push(`šŸ“ ${file.name}`);
332
+ }
333
+ } else {
334
+ fileNames.push(`šŸ“„ ${file.name}`);
335
+ }
336
+ });
337
+
338
+ if (dirButtons.length > 0) {
339
+ // Slack allows max 5 buttons per action block
340
+ for (let i = 0; i < dirButtons.length; i += 5) {
341
+ blocks.push({
342
+ type: 'actions',
343
+ elements: dirButtons.slice(i, i + 5)
344
+ });
345
+ }
346
+ }
347
+
348
+ if (fileNames.length > 0) {
349
+ let fileListText = fileNames.join('\n');
350
+ if (fileListText.length > 2500) {
351
+ fileListText = fileListText.substring(0, 2500) + '\n...(truncated)';
352
+ }
353
+ blocks.push({
354
+ type: 'section',
355
+ text: {
356
+ type: 'mrkdwn',
357
+ text: fileListText
358
+ }
359
+ });
360
+ }
361
+
362
+ return {
363
+ text: `Directory: ${newPath}`,
364
+ blocks: blocks
365
+ };
366
+
367
+ } catch (error: any) {
368
+ return { text: `āŒ Error accessing directory: ${error.message}` };
369
+ }
370
+ };
371
+
372
+ // Handle navigate_dir action
373
+ app.action('navigate_dir', async ({ ack, body, respond }) => {
374
+ await ack();
375
+ const action = (body as any).actions[0];
376
+ const contextId = (body as any).container.thread_ts || (body as any).container.channel_id;
377
+
378
+ const output = await executeChangeDirectory(contextId, action.value);
379
+ await respond({
380
+ ...output,
381
+ replace_original: true
382
+ });
383
+ });
384
+
385
+ // Handle /plan command
386
+ app.command('/plan', async ({ command, ack, say, client }) => {
387
+ await ack();
388
+ const contextId = command.channel_id;
389
+ const goal = command.text.trim();
390
+
391
+ // Switch to Plan Mode
392
+ contextManager.setContextData(contextId, { mode: 'plan' });
393
+
394
+ // Notify initiation
395
+ await say({
396
+ text: `šŸ“ *Entering Plan Mode*\nTarget: ${goal || "No specific goal provided. What would you like to plan?"}`,
397
+ channel: command.channel_id
398
+ });
399
+
400
+ // If goal provided, process it as the first message
401
+ if (goal) {
402
+ await processAgentTurn(contextId, command.channel_id, undefined, goal, say, client);
403
+ }
404
+ });
405
+
406
+ // Handle /cd command
407
+ app.command('/cd', async ({ command, ack, respond }) => {
408
+ await ack();
409
+ const output = await executeChangeDirectory(command.channel_id, command.text.trim());
410
+ await respond(output);
411
+ });
412
+
413
+ // Handle "cd [path]" messages (works in threads)
414
+ app.message(/^cd\s+(.+)/, async ({ message, context, say }) => {
415
+ // Need to cast message to access optional properties like thread_ts
416
+ const msg = message as any;
417
+ const targetPath = context.matches[1].trim();
418
+
419
+ // Use thread_ts if available, otherwise channel
420
+ const contextId = msg.thread_ts || msg.channel;
421
+
422
+ const output = await executeChangeDirectory(contextId, targetPath);
423
+
424
+ await say({
425
+ text: output,
426
+ thread_ts: msg.thread_ts // Reply in thread if it was a thread message
427
+ });
428
+ });
429
+
430
+ // Handle /model command
431
+ app.command('/model', async ({ command, ack, client, respond }) => {
432
+ await ack();
433
+ const input = command.text.trim();
434
+ const contextId = command.channel_id;
435
+
436
+ const context = contextManager.getContext(contextId);
437
+
438
+ // Show modal if no input
439
+ if (!input) {
440
+ await client.views.open({
441
+ trigger_id: command.trigger_id,
442
+ view: {
443
+ type: 'modal',
444
+ callback_id: 'model_select_modal',
445
+ private_metadata: contextId,
446
+ title: { type: 'plain_text', text: 'Select Model' },
447
+ blocks: [
448
+ {
449
+ type: 'input',
450
+ block_id: 'model_block',
451
+ label: { type: 'plain_text', text: 'Choose a Gemini model' },
452
+ element: {
453
+ type: 'static_select',
454
+ action_id: 'model_select',
455
+ initial_option: AVAILABLE_MODELS.find(m => m.id === (context.data.model || 'gemini-1.5-pro')) ? {
456
+ text: { type: 'plain_text', text: context.data.model || 'gemini-1.5-pro' },
457
+ value: context.data.model || 'gemini-1.5-pro'
458
+ } : undefined,
459
+ options: AVAILABLE_MODELS.map(m => ({
460
+ text: { type: 'plain_text', text: m.id },
461
+ value: m.id,
462
+ description: { type: 'plain_text', text: m.desc.substring(0, 75) }
463
+ }))
464
+ }
465
+ }
466
+ ],
467
+ submit: { type: 'plain_text', text: 'Update' }
468
+ }
469
+ });
470
+ return;
471
+ }
472
+
473
+ if (input === 'list' || input === 'help') {
474
+ const currentModel = context.data.model || 'default';
475
+ const modelList = AVAILABLE_MODELS.map(m => `• \`${m.id}\`: ${m.desc}`).join('\n');
476
+
477
+ await respond(
478
+ `Current model: \`${currentModel}\`\n\n` +
479
+ `*Available Models:*\n${modelList}\n\n` +
480
+ `To switch, use \`/model <model_name>\``
481
+ );
482
+ return;
483
+ }
484
+
485
+ contextManager.setContextData(contextId, { model: input });
486
+ await respond(`Switched model to: \`${input}\``);
487
+ });
488
+
489
+ // Handle /agent command
490
+ app.command('/agent', async ({ command, ack, client, respond }) => {
491
+ await ack();
492
+ const input = command.text.trim();
493
+ const contextId = command.channel_id;
494
+
495
+ const context = contextManager.getContext(contextId);
496
+
497
+ if (!input) {
498
+ await client.views.open({
499
+ trigger_id: command.trigger_id,
500
+ view: {
501
+ type: 'modal',
502
+ callback_id: 'agent_select_modal',
503
+ private_metadata: contextId,
504
+ title: { type: 'plain_text', text: 'Select Agent' },
505
+ blocks: [
506
+ {
507
+ type: 'input',
508
+ block_id: 'agent_block',
509
+ label: { type: 'plain_text', text: 'Choose a backing agent' },
510
+ element: {
511
+ type: 'static_select',
512
+ action_id: 'agent_select',
513
+ initial_option: {
514
+ text: { type: 'plain_text', text: context.data.agent || 'gemini' },
515
+ value: context.data.agent || 'gemini'
516
+ },
517
+ options: Object.keys(agentRegistry).map(k => ({
518
+ text: { type: 'plain_text', text: k },
519
+ value: k
520
+ }))
521
+ }
522
+ }
523
+ ],
524
+ submit: { type: 'plain_text', text: 'Update' }
525
+ }
526
+ });
527
+ return;
528
+ }
529
+
530
+ if (input === 'list' || input === 'help') {
531
+ const currentAgent = context.data.agent || 'gemini';
532
+ const agentList = Object.keys(agentRegistry).map(k => `• \`${k}\``).join('\n');
533
+
534
+ await respond(
535
+ `Current agent: \`${currentAgent}\`\n\n` +
536
+ `*Available Agents:*\n${agentList}\n\n` +
537
+ `To switch, use \`/agent <agent_name>\``
538
+ );
539
+ return;
540
+ }
541
+
542
+ const agentName = input.toLowerCase();
543
+ if (!agentRegistry[agentName]) {
544
+ await respond(`āŒ Unknown agent: \`${agentName}\`. Available agents: ${Object.keys(agentRegistry).join(', ')}`);
545
+ return;
546
+ }
547
+
548
+ contextManager.setContextData(contextId, { agent: agentName });
549
+ await respond(`Switched agent to: \`${agentName}\``);
550
+ });
551
+
552
+ // Handle Modal Submissions
553
+ app.view('model_select_modal', async ({ ack, view, client }) => {
554
+ await ack();
555
+ const contextId = view.private_metadata;
556
+ const selectedModel = view.state.values.model_block.model_select.selected_option?.value;
557
+
558
+ if (selectedModel) {
559
+ contextManager.setContextData(contextId, { model: selectedModel });
560
+ await client.chat.postMessage({
561
+ channel: contextId,
562
+ text: `šŸ”„ Model updated to \`${selectedModel}\``
563
+ });
564
+ }
565
+ });
566
+
567
+ app.view('agent_select_modal', async ({ ack, view, client }) => {
568
+ await ack();
569
+ const contextId = view.private_metadata;
570
+ const selectedAgent = view.state.values.agent_block.agent_select.selected_option?.value;
571
+
572
+ if (selectedAgent) {
573
+ contextManager.setContextData(contextId, { agent: selectedAgent });
574
+ await client.chat.postMessage({
575
+ channel: contextId,
576
+ text: `šŸ”„ Agent updated to \`${selectedAgent}\``
577
+ });
578
+ }
579
+ });
580
+
581
+ // Handle Retry
582
+ app.action('retry_turn', async ({ ack, body, client }) => {
583
+ await ack();
584
+ const action = (body as any);
585
+ const contextId = action.container.thread_ts || action.container.channel_id;
586
+ const channelId = action.container.channel_id;
587
+ const messageTs = action.container.message_ts;
588
+ const threadTs = action.container.thread_ts;
589
+
590
+ const context = contextManager.getContext(contextId);
591
+
592
+ // Remove last assistant message
593
+ if (context.messages.length > 0 && context.messages[context.messages.length - 1].role === 'assistant') {
594
+ context.messages.pop();
595
+ }
596
+
597
+ // Add a note to the last user message if not already present
598
+ if (context.messages.length > 0 && context.messages[context.messages.length - 1].role === 'user') {
599
+ const lastMsg = context.messages[context.messages.length - 1];
600
+ if (!lastMsg.content.includes('[System: The user requested a retry')) {
601
+ lastMsg.content += '\n\n[System: The user requested a retry of this instruction.]';
602
+ }
603
+ }
604
+
605
+ contextManager.setContextData(contextId, { messages: context.messages });
606
+
607
+ // Polyfill say using client
608
+ const say = async (args: any) => {
609
+ const msg = typeof args === 'string' ? { text: args } : args;
610
+ return await client.chat.postMessage({
611
+ channel: channelId,
612
+ ...msg
613
+ });
614
+ };
615
+
616
+ // Re-run agent turn (without new user text, using existing message to update)
617
+ await processAgentTurn(contextId, channelId, threadTs, undefined, say, client, messageTs);
618
+ });
619
+
620
+ // Handle Revert
621
+ app.action('revert_turn', async ({ ack, body, client }) => {
622
+ await ack();
623
+ const action = (body as any);
624
+ const contextId = action.container.thread_ts || action.container.channel_id;
625
+ const channelId = action.container.channel_id;
626
+ const threadTs = action.container.thread_ts;
627
+
628
+ const context = contextManager.getContext(contextId);
629
+
630
+ // Find the last user message to include in the revert prompt
631
+ let lastUserText = '';
632
+ for (let i = context.messages.length - 1; i >= 0; i--) {
633
+ if (context.messages[i].role === 'user') {
634
+ lastUserText = context.messages[i].content;
635
+ break;
636
+ }
637
+ }
638
+
639
+ // Polyfill say using client
640
+ const say = async (args: any) => {
641
+ const msg = typeof args === 'string' ? { text: args } : args;
642
+ return await client.chat.postMessage({
643
+ channel: channelId,
644
+ ...msg
645
+ });
646
+ };
647
+
648
+ const revertPrompt = `The user wants to revert the actions taken for the following request: "${lastUserText}". Please undo any file modifications or state changes made in that turn.`;
649
+
650
+ // Trigger a new turn with instructions to revert
651
+ // We do NOT pop messages here, we want the agent to see what it did and undo it.
652
+ await processAgentTurn(
653
+ contextId,
654
+ channelId,
655
+ threadTs,
656
+ revertPrompt,
657
+ say,
658
+ client
659
+ );
660
+ });
661
+
662
+ // Handle /help command
663
+ app.command('/help', async ({ ack, respond }) => {
664
+ await ack();
665
+ const helpMessage = `
666
+ *Available Commands:*
667
+
668
+ - \`/plan [goal]\`: Enter plan mode to generate and execute coding plans.
669
+ - \`/cd [path]\`: Change the current working directory for the conversation.
670
+ - \`/model [name]\`: Switch the Gemini model (e.g., \`gemini-pro\`, \`gemini-1.5-pro\`).
671
+ - \`/agent [name]\`: Switch the backing agent (e.g., \`gemini\`, \`claude\`, \`cursor\`).
672
+ - \`/help\`: Show this help message.
673
+ `;
674
+ await respond(helpMessage);
675
+ });
676
+
677
+ // Handle App Home Tab
678
+ app.event('app_home_opened', async ({ event, client, logger }) => {
679
+ try {
680
+ const contexts = contextManager.getAllContexts();
681
+ // Sort by last active
682
+ contexts.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime());
683
+
684
+ const contextBlocks = contexts.slice(0, 5).map(ctx => ({
685
+ type: 'section',
686
+ text: {
687
+ type: 'mrkdwn',
688
+ text: `*Context:* \`${ctx.id}\`\n*Last Active:* ${ctx.lastActiveAt.toLocaleString()}\n*Agent:* \`${ctx.data.agent || 'gemini'}\` | *Model:* \`${ctx.data.model || 'default'}\` | *CWD:* \`${ctx.data.cwd || 'root'}\``
689
+ }
690
+ }));
691
+
692
+ const blocks: any[] = [
693
+ {
694
+ type: 'header',
695
+ text: {
696
+ type: 'plain_text',
697
+ text: 'šŸ  Gibi Dashboard',
698
+ emoji: true
699
+ }
700
+ },
701
+ {
702
+ type: 'section',
703
+ text: {
704
+ type: 'mrkdwn',
705
+ text: `Welcome, <@${event.user}>! Here is a summary of your Gibi instance status.`
706
+ }
707
+ },
708
+ {
709
+ type: 'divider'
710
+ },
711
+ {
712
+ type: 'section',
713
+ fields: [
714
+ {
715
+ type: 'mrkdwn',
716
+ text: `*Default Agent:*\n\`gemini\``
717
+ },
718
+ {
719
+ type: 'mrkdwn',
720
+ text: `*Available Agents:*\n${Object.keys(agentRegistry).map(k => `\`${k}\``).join(', ')}`
721
+ }
722
+ ]
723
+ },
724
+ {
725
+ type: 'divider'
726
+ },
727
+ {
728
+ type: 'header',
729
+ text: {
730
+ type: 'plain_text',
731
+ text: 'šŸ•’ Recent Activity',
732
+ emoji: true
733
+ }
734
+ }
735
+ ];
736
+
737
+ if (contextBlocks.length > 0) {
738
+ blocks.push(...contextBlocks.flatMap(b => [b, { type: 'divider' }]));
739
+ } else {
740
+ blocks.push({
741
+ type: 'section',
742
+ text: {
743
+ type: 'mrkdwn',
744
+ text: '_No active contexts found. Send a message to Gibi in any channel to start._'
745
+ }
746
+ });
747
+ }
748
+
749
+ blocks.push({
750
+ type: 'context',
751
+ elements: [
752
+ {
753
+ type: 'mrkdwn',
754
+ text: `Last updated: ${new Date().toLocaleString()}`
755
+ }
756
+ ]
757
+ });
758
+
759
+ await client.views.publish({
760
+ user_id: event.user,
761
+ view: {
762
+ type: 'home',
763
+ blocks: blocks
764
+ }
765
+ });
766
+ } catch (error) {
767
+ logger.error('Error publishing Home tab:', error);
768
+ }
769
+ });
770
+
771
+ // Start your app
772
+ await app.start(process.env.PORT || 3000);
773
+ console.log('šŸ’ Gibi is running!');
774
+
775
+ } catch (error: any) {
776
+ await handleAuthError(error);
777
+ }
778
+ };
779
+
780
+ const handleAuthError = async (error: any) => {
781
+ if (isRestarting) return;
782
+
783
+ if (error?.data?.error === 'invalid_auth') {
784
+ isRestarting = true;
785
+ console.error('\nāŒ Authentication failed: The stored Slack tokens seem to be invalid.');
786
+ console.error(' Clearing stored configuration and restarting setup...');
787
+
788
+ await clearConfig(['SLACK_BOT_TOKEN']);
789
+
790
+ console.log('\nšŸ”„ Restarting app setup...\n');
791
+ isRestarting = false;
792
+ await start(); // Recursively restart
793
+ } else {
794
+ console.error('Failed to start app:', error);
795
+ process.exit(1);
796
+ }
797
+ };
798
+
799
+ // Start the application
800
+ start();
801
+
802
+ // Global error handlers to catch any unhandled promise rejections
803
+ process.on('unhandledRejection', async (reason: any) => {
804
+ await handleAuthError(reason);
805
+ });
806
+
807
+ process.on('uncaughtException', async (error: any) => {
808
+ await handleAuthError(error);
809
+ });