shuvmaki 0.4.26

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 (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,129 @@
1
+ // Queue commands - /queue, /clear-queue
2
+ import { ChannelType } from 'discord.js';
3
+ import { getDatabase } from '../database.js';
4
+ import { resolveTextChannel, getKimakiMetadata, sendThreadMessage, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
5
+ import { handleOpencodeSession, abortControllers, addToQueue, getQueueLength, clearQueue, } from '../session-handler.js';
6
+ import { createLogger } from '../logger.js';
7
+ const logger = createLogger('QUEUE');
8
+ export async function handleQueueCommand({ command, }) {
9
+ const message = command.options.getString('message', true);
10
+ const channel = command.channel;
11
+ if (!channel) {
12
+ await command.reply({
13
+ content: 'This command can only be used in a channel',
14
+ ephemeral: true,
15
+ flags: SILENT_MESSAGE_FLAGS,
16
+ });
17
+ return;
18
+ }
19
+ const isThread = [
20
+ ChannelType.PublicThread,
21
+ ChannelType.PrivateThread,
22
+ ChannelType.AnnouncementThread,
23
+ ].includes(channel.type);
24
+ if (!isThread) {
25
+ await command.reply({
26
+ content: 'This command can only be used in a thread with an active session',
27
+ ephemeral: true,
28
+ flags: SILENT_MESSAGE_FLAGS,
29
+ });
30
+ return;
31
+ }
32
+ const row = getDatabase()
33
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
34
+ .get(channel.id);
35
+ if (!row?.session_id) {
36
+ await command.reply({
37
+ content: 'No active session in this thread. Send a message directly instead.',
38
+ ephemeral: true,
39
+ flags: SILENT_MESSAGE_FLAGS,
40
+ });
41
+ return;
42
+ }
43
+ // Check if there's an active request running
44
+ const hasActiveRequest = abortControllers.has(row.session_id);
45
+ if (!hasActiveRequest) {
46
+ // No active request, send immediately
47
+ const textChannel = await resolveTextChannel(channel);
48
+ const { projectDirectory } = getKimakiMetadata(textChannel);
49
+ if (!projectDirectory) {
50
+ await command.reply({
51
+ content: 'Could not determine project directory',
52
+ ephemeral: true,
53
+ flags: SILENT_MESSAGE_FLAGS,
54
+ });
55
+ return;
56
+ }
57
+ await command.reply({
58
+ content: `» **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
59
+ flags: SILENT_MESSAGE_FLAGS,
60
+ });
61
+ logger.log(`[QUEUE] No active request, sending immediately in thread ${channel.id}`);
62
+ handleOpencodeSession({
63
+ prompt: message,
64
+ thread: channel,
65
+ projectDirectory,
66
+ channelId: textChannel?.id || channel.id,
67
+ }).catch(async (e) => {
68
+ logger.error(`[QUEUE] Failed to send message:`, e);
69
+ const errorMsg = e instanceof Error ? e.message : String(e);
70
+ await sendThreadMessage(channel, `✗ Failed: ${errorMsg.slice(0, 200)}`);
71
+ });
72
+ return;
73
+ }
74
+ // Add to queue
75
+ const queuePosition = addToQueue({
76
+ threadId: channel.id,
77
+ message: {
78
+ prompt: message,
79
+ userId: command.user.id,
80
+ username: command.user.displayName,
81
+ queuedAt: Date.now(),
82
+ },
83
+ });
84
+ await command.reply({
85
+ content: `✅ Message queued (position: ${queuePosition}). Will be sent after current response.`,
86
+ ephemeral: true,
87
+ flags: SILENT_MESSAGE_FLAGS,
88
+ });
89
+ logger.log(`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`);
90
+ }
91
+ export async function handleClearQueueCommand({ command, }) {
92
+ const channel = command.channel;
93
+ if (!channel) {
94
+ await command.reply({
95
+ content: 'This command can only be used in a channel',
96
+ ephemeral: true,
97
+ flags: SILENT_MESSAGE_FLAGS,
98
+ });
99
+ return;
100
+ }
101
+ const isThread = [
102
+ ChannelType.PublicThread,
103
+ ChannelType.PrivateThread,
104
+ ChannelType.AnnouncementThread,
105
+ ].includes(channel.type);
106
+ if (!isThread) {
107
+ await command.reply({
108
+ content: 'This command can only be used in a thread',
109
+ ephemeral: true,
110
+ flags: SILENT_MESSAGE_FLAGS,
111
+ });
112
+ return;
113
+ }
114
+ const queueLength = getQueueLength(channel.id);
115
+ if (queueLength === 0) {
116
+ await command.reply({
117
+ content: 'No messages in queue',
118
+ ephemeral: true,
119
+ flags: SILENT_MESSAGE_FLAGS,
120
+ });
121
+ return;
122
+ }
123
+ clearQueue(channel.id);
124
+ await command.reply({
125
+ content: `🗑 Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
126
+ flags: SILENT_MESSAGE_FLAGS,
127
+ });
128
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
129
+ }
@@ -0,0 +1,145 @@
1
+ // /resume command - Resume an existing OpenCode session.
2
+ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
+ import fs from 'node:fs';
4
+ import { getDatabase } from '../database.js';
5
+ import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata, } from '../discord-utils.js';
7
+ import { extractTagsArrays } from '../xml.js';
8
+ import { collectLastAssistantParts } from '../message-formatting.js';
9
+ import { createLogger } from '../logger.js';
10
+ const logger = createLogger('RESUME');
11
+ export async function handleResumeCommand({ command, appId, }) {
12
+ await command.deferReply({ ephemeral: false });
13
+ const sessionId = command.options.getString('session', true);
14
+ const channel = command.channel;
15
+ if (!channel || channel.type !== ChannelType.GuildText) {
16
+ await command.editReply('This command can only be used in text channels');
17
+ return;
18
+ }
19
+ const textChannel = channel;
20
+ let projectDirectory;
21
+ let channelAppId;
22
+ if (textChannel.topic) {
23
+ const extracted = extractTagsArrays({
24
+ xml: textChannel.topic,
25
+ tags: ['kimaki.directory', 'kimaki.app'],
26
+ });
27
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
28
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
29
+ }
30
+ if (channelAppId && channelAppId !== appId) {
31
+ await command.editReply('This channel is not configured for this bot');
32
+ return;
33
+ }
34
+ if (!projectDirectory) {
35
+ await command.editReply('This channel is not configured with a project directory');
36
+ return;
37
+ }
38
+ if (!fs.existsSync(projectDirectory)) {
39
+ await command.editReply(`Directory does not exist: ${projectDirectory}`);
40
+ return;
41
+ }
42
+ try {
43
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
44
+ const sessionResponse = await getClient().session.get({
45
+ path: { id: sessionId },
46
+ });
47
+ if (!sessionResponse.data) {
48
+ await command.editReply('Session not found');
49
+ return;
50
+ }
51
+ const sessionTitle = sessionResponse.data.title;
52
+ const thread = await textChannel.threads.create({
53
+ name: `Resume: ${sessionTitle}`.slice(0, 100),
54
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
55
+ reason: `Resuming session ${sessionId}`,
56
+ });
57
+ getDatabase()
58
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
59
+ .run(thread.id, sessionId);
60
+ logger.log(`[RESUME] Created thread ${thread.id} for session ${sessionId}`);
61
+ const messagesResponse = await getClient().session.messages({
62
+ path: { id: sessionId },
63
+ });
64
+ if (!messagesResponse.data) {
65
+ throw new Error('Failed to fetch session messages');
66
+ }
67
+ const messages = messagesResponse.data;
68
+ await command.editReply(`Resumed session "${sessionTitle}" in ${thread.toString()}`);
69
+ await sendThreadMessage(thread, `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
70
+ const { partIds, content, skippedCount } = collectLastAssistantParts({
71
+ messages,
72
+ });
73
+ if (skippedCount > 0) {
74
+ await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`);
75
+ }
76
+ if (content.trim()) {
77
+ const discordMessage = await sendThreadMessage(thread, content);
78
+ const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
79
+ const transaction = getDatabase().transaction((ids) => {
80
+ for (const partId of ids) {
81
+ stmt.run(partId, discordMessage.id, thread.id);
82
+ }
83
+ });
84
+ transaction(partIds);
85
+ }
86
+ const messageCount = messages.length;
87
+ await sendThreadMessage(thread, `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
88
+ }
89
+ catch (error) {
90
+ logger.error('[RESUME] Error:', error);
91
+ await command.editReply(`Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`);
92
+ }
93
+ }
94
+ export async function handleResumeAutocomplete({ interaction, appId, }) {
95
+ const focusedValue = interaction.options.getFocused();
96
+ let projectDirectory;
97
+ if (interaction.channel) {
98
+ const textChannel = await resolveTextChannel(interaction.channel);
99
+ if (textChannel) {
100
+ const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
101
+ if (channelAppId && channelAppId !== appId) {
102
+ await interaction.respond([]);
103
+ return;
104
+ }
105
+ projectDirectory = directory;
106
+ }
107
+ }
108
+ if (!projectDirectory) {
109
+ await interaction.respond([]);
110
+ return;
111
+ }
112
+ try {
113
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
114
+ const sessionsResponse = await getClient().session.list();
115
+ if (!sessionsResponse.data) {
116
+ await interaction.respond([]);
117
+ return;
118
+ }
119
+ const existingSessionIds = new Set(getDatabase()
120
+ .prepare('SELECT session_id FROM thread_sessions')
121
+ .all().map((row) => row.session_id));
122
+ const sessions = sessionsResponse.data
123
+ .filter((session) => !existingSessionIds.has(session.id))
124
+ .filter((session) => session.title.toLowerCase().includes(focusedValue.toLowerCase()))
125
+ .slice(0, 25)
126
+ .map((session) => {
127
+ const dateStr = new Date(session.time.updated).toLocaleString();
128
+ const suffix = ` (${dateStr})`;
129
+ const maxTitleLength = 100 - suffix.length;
130
+ let title = session.title;
131
+ if (title.length > maxTitleLength) {
132
+ title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…';
133
+ }
134
+ return {
135
+ name: `${title}${suffix}`,
136
+ value: session.id,
137
+ };
138
+ });
139
+ await interaction.respond(sessions);
140
+ }
141
+ catch (error) {
142
+ logger.error('[AUTOCOMPLETE] Error fetching sessions:', error);
143
+ await interaction.respond([]);
144
+ }
145
+ }
@@ -0,0 +1,142 @@
1
+ // /session command - Start a new OpenCode session.
2
+ import { ChannelType } from 'discord.js';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { getDatabase } from '../database.js';
6
+ import { initializeOpencodeForDirectory } from '../opencode.js';
7
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
8
+ import { extractTagsArrays } from '../xml.js';
9
+ import { handleOpencodeSession } from '../session-handler.js';
10
+ import { createLogger } from '../logger.js';
11
+ const logger = createLogger('SESSION');
12
+ export async function handleSessionCommand({ command, appId, }) {
13
+ await command.deferReply({ ephemeral: false });
14
+ const prompt = command.options.getString('prompt', true);
15
+ const filesString = command.options.getString('files') || '';
16
+ const channel = command.channel;
17
+ if (!channel || channel.type !== ChannelType.GuildText) {
18
+ await command.editReply('This command can only be used in text channels');
19
+ return;
20
+ }
21
+ const textChannel = channel;
22
+ let projectDirectory;
23
+ let channelAppId;
24
+ if (textChannel.topic) {
25
+ const extracted = extractTagsArrays({
26
+ xml: textChannel.topic,
27
+ tags: ['kimaki.directory', 'kimaki.app'],
28
+ });
29
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
30
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
31
+ }
32
+ if (channelAppId && channelAppId !== appId) {
33
+ await command.editReply('This channel is not configured for this bot');
34
+ return;
35
+ }
36
+ if (!projectDirectory) {
37
+ await command.editReply('This channel is not configured with a project directory');
38
+ return;
39
+ }
40
+ if (!fs.existsSync(projectDirectory)) {
41
+ await command.editReply(`Directory does not exist: ${projectDirectory}`);
42
+ return;
43
+ }
44
+ try {
45
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
46
+ const files = filesString
47
+ .split(',')
48
+ .map((f) => f.trim())
49
+ .filter((f) => f);
50
+ let fullPrompt = prompt;
51
+ if (files.length > 0) {
52
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`;
53
+ }
54
+ const starterMessage = await textChannel.send({
55
+ content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
56
+ flags: SILENT_MESSAGE_FLAGS,
57
+ });
58
+ const thread = await starterMessage.startThread({
59
+ name: prompt.slice(0, 100),
60
+ autoArchiveDuration: 1440,
61
+ reason: 'OpenCode session',
62
+ });
63
+ await command.editReply(`Created new session in ${thread.toString()}`);
64
+ await handleOpencodeSession({
65
+ prompt: fullPrompt,
66
+ thread,
67
+ projectDirectory,
68
+ channelId: textChannel.id,
69
+ });
70
+ }
71
+ catch (error) {
72
+ logger.error('[SESSION] Error:', error);
73
+ await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
74
+ }
75
+ }
76
+ export async function handleSessionAutocomplete({ interaction, appId, }) {
77
+ const focusedOption = interaction.options.getFocused(true);
78
+ if (focusedOption.name !== 'files') {
79
+ return;
80
+ }
81
+ const focusedValue = focusedOption.value;
82
+ const parts = focusedValue.split(',');
83
+ const previousFiles = parts
84
+ .slice(0, -1)
85
+ .map((f) => f.trim())
86
+ .filter((f) => f);
87
+ const currentQuery = (parts[parts.length - 1] || '').trim();
88
+ let projectDirectory;
89
+ if (interaction.channel) {
90
+ const channel = interaction.channel;
91
+ if (channel.type === ChannelType.GuildText) {
92
+ const textChannel = channel;
93
+ if (textChannel.topic) {
94
+ const extracted = extractTagsArrays({
95
+ xml: textChannel.topic,
96
+ tags: ['kimaki.directory', 'kimaki.app'],
97
+ });
98
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
99
+ if (channelAppId && channelAppId !== appId) {
100
+ await interaction.respond([]);
101
+ return;
102
+ }
103
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
104
+ }
105
+ }
106
+ }
107
+ if (!projectDirectory) {
108
+ await interaction.respond([]);
109
+ return;
110
+ }
111
+ try {
112
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
113
+ const response = await getClient().find.files({
114
+ query: {
115
+ query: currentQuery || '',
116
+ },
117
+ });
118
+ const files = response.data || [];
119
+ const prefix = previousFiles.length > 0 ? previousFiles.join(', ') + ', ' : '';
120
+ const choices = files
121
+ .map((file) => {
122
+ const fullValue = prefix + file;
123
+ const allFiles = [...previousFiles, file];
124
+ const allBasenames = allFiles.map((f) => f.split('/').pop() || f);
125
+ let displayName = allBasenames.join(', ');
126
+ if (displayName.length > 100) {
127
+ displayName = '…' + displayName.slice(-97);
128
+ }
129
+ return {
130
+ name: displayName,
131
+ value: fullValue,
132
+ };
133
+ })
134
+ .filter((choice) => choice.value.length <= 100)
135
+ .slice(0, 25);
136
+ await interaction.respond(choices);
137
+ }
138
+ catch (error) {
139
+ logger.error('[AUTOCOMPLETE] Error fetching files:', error);
140
+ await interaction.respond([]);
141
+ }
142
+ }
@@ -0,0 +1,80 @@
1
+ // /share command - Share the current session as a public URL.
2
+ import { ChannelType } from 'discord.js';
3
+ import { getDatabase } from '../database.js';
4
+ import { initializeOpencodeForDirectory } from '../opencode.js';
5
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
+ import { createLogger } from '../logger.js';
7
+ const logger = createLogger('SHARE');
8
+ export async function handleShareCommand({ command, }) {
9
+ const channel = command.channel;
10
+ if (!channel) {
11
+ await command.reply({
12
+ content: 'This command can only be used in a channel',
13
+ ephemeral: true,
14
+ flags: SILENT_MESSAGE_FLAGS,
15
+ });
16
+ return;
17
+ }
18
+ const isThread = [
19
+ ChannelType.PublicThread,
20
+ ChannelType.PrivateThread,
21
+ ChannelType.AnnouncementThread,
22
+ ].includes(channel.type);
23
+ if (!isThread) {
24
+ await command.reply({
25
+ content: 'This command can only be used in a thread with an active session',
26
+ ephemeral: true,
27
+ flags: SILENT_MESSAGE_FLAGS,
28
+ });
29
+ return;
30
+ }
31
+ const textChannel = await resolveTextChannel(channel);
32
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel);
33
+ if (!directory) {
34
+ await command.reply({
35
+ content: 'Could not determine project directory for this channel',
36
+ ephemeral: true,
37
+ flags: SILENT_MESSAGE_FLAGS,
38
+ });
39
+ return;
40
+ }
41
+ const row = getDatabase()
42
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
43
+ .get(channel.id);
44
+ if (!row?.session_id) {
45
+ await command.reply({
46
+ content: 'No active session in this thread',
47
+ ephemeral: true,
48
+ flags: SILENT_MESSAGE_FLAGS,
49
+ });
50
+ return;
51
+ }
52
+ const sessionId = row.session_id;
53
+ try {
54
+ const getClient = await initializeOpencodeForDirectory(directory);
55
+ const response = await getClient().session.share({
56
+ path: { id: sessionId },
57
+ });
58
+ if (!response.data?.share?.url) {
59
+ await command.reply({
60
+ content: 'Failed to generate share URL',
61
+ ephemeral: true,
62
+ flags: SILENT_MESSAGE_FLAGS,
63
+ });
64
+ return;
65
+ }
66
+ await command.reply({
67
+ content: `🔗 **Session shared:** ${response.data.share.url}`,
68
+ flags: SILENT_MESSAGE_FLAGS,
69
+ });
70
+ logger.log(`Session ${sessionId} shared: ${response.data.share.url}`);
71
+ }
72
+ catch (error) {
73
+ logger.error('[SHARE] Error:', error);
74
+ await command.reply({
75
+ content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
76
+ ephemeral: true,
77
+ flags: SILENT_MESSAGE_FLAGS,
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,2 @@
1
+ // Shared types for command handlers.
2
+ export {};
@@ -0,0 +1,161 @@
1
+ // Undo/Redo commands - /undo, /redo
2
+ import { ChannelType } from 'discord.js';
3
+ import { getDatabase } from '../database.js';
4
+ import { initializeOpencodeForDirectory } from '../opencode.js';
5
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
+ import { createLogger } from '../logger.js';
7
+ const logger = createLogger('UNDO-REDO');
8
+ export async function handleUndoCommand({ command, }) {
9
+ const channel = command.channel;
10
+ if (!channel) {
11
+ await command.reply({
12
+ content: 'This command can only be used in a channel',
13
+ ephemeral: true,
14
+ flags: SILENT_MESSAGE_FLAGS,
15
+ });
16
+ return;
17
+ }
18
+ const isThread = [
19
+ ChannelType.PublicThread,
20
+ ChannelType.PrivateThread,
21
+ ChannelType.AnnouncementThread,
22
+ ].includes(channel.type);
23
+ if (!isThread) {
24
+ await command.reply({
25
+ content: 'This command can only be used in a thread with an active session',
26
+ ephemeral: true,
27
+ flags: SILENT_MESSAGE_FLAGS,
28
+ });
29
+ return;
30
+ }
31
+ const textChannel = await resolveTextChannel(channel);
32
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel);
33
+ if (!directory) {
34
+ await command.reply({
35
+ content: 'Could not determine project directory for this channel',
36
+ ephemeral: true,
37
+ flags: SILENT_MESSAGE_FLAGS,
38
+ });
39
+ return;
40
+ }
41
+ const row = getDatabase()
42
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
43
+ .get(channel.id);
44
+ if (!row?.session_id) {
45
+ await command.reply({
46
+ content: 'No active session in this thread',
47
+ ephemeral: true,
48
+ flags: SILENT_MESSAGE_FLAGS,
49
+ });
50
+ return;
51
+ }
52
+ const sessionId = row.session_id;
53
+ try {
54
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
55
+ const getClient = await initializeOpencodeForDirectory(directory);
56
+ // Fetch messages to find the last assistant message
57
+ const messagesResponse = await getClient().session.messages({
58
+ path: { id: sessionId },
59
+ });
60
+ if (!messagesResponse.data || messagesResponse.data.length === 0) {
61
+ await command.editReply('No messages to undo');
62
+ return;
63
+ }
64
+ // Find the last assistant message
65
+ const lastAssistantMessage = [...messagesResponse.data]
66
+ .reverse()
67
+ .find((m) => m.info.role === 'assistant');
68
+ if (!lastAssistantMessage) {
69
+ await command.editReply('No assistant message to undo');
70
+ return;
71
+ }
72
+ const response = await getClient().session.revert({
73
+ path: { id: sessionId },
74
+ body: { messageID: lastAssistantMessage.info.id },
75
+ });
76
+ if (response.error) {
77
+ await command.editReply(`Failed to undo: ${JSON.stringify(response.error)}`);
78
+ return;
79
+ }
80
+ const diffInfo = response.data?.revert?.diff
81
+ ? `\n\`\`\`diff\n${response.data.revert.diff.slice(0, 1500)}\n\`\`\``
82
+ : '';
83
+ await command.editReply(`⏪ **Undone** - reverted last assistant message${diffInfo}`);
84
+ logger.log(`Session ${sessionId} reverted message ${lastAssistantMessage.info.id}`);
85
+ }
86
+ catch (error) {
87
+ logger.error('[UNDO] Error:', error);
88
+ await command.editReply(`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`);
89
+ }
90
+ }
91
+ export async function handleRedoCommand({ command, }) {
92
+ const channel = command.channel;
93
+ if (!channel) {
94
+ await command.reply({
95
+ content: 'This command can only be used in a channel',
96
+ ephemeral: true,
97
+ flags: SILENT_MESSAGE_FLAGS,
98
+ });
99
+ return;
100
+ }
101
+ const isThread = [
102
+ ChannelType.PublicThread,
103
+ ChannelType.PrivateThread,
104
+ ChannelType.AnnouncementThread,
105
+ ].includes(channel.type);
106
+ if (!isThread) {
107
+ await command.reply({
108
+ content: 'This command can only be used in a thread with an active session',
109
+ ephemeral: true,
110
+ flags: SILENT_MESSAGE_FLAGS,
111
+ });
112
+ return;
113
+ }
114
+ const textChannel = await resolveTextChannel(channel);
115
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel);
116
+ if (!directory) {
117
+ await command.reply({
118
+ content: 'Could not determine project directory for this channel',
119
+ ephemeral: true,
120
+ flags: SILENT_MESSAGE_FLAGS,
121
+ });
122
+ return;
123
+ }
124
+ const row = getDatabase()
125
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
126
+ .get(channel.id);
127
+ if (!row?.session_id) {
128
+ await command.reply({
129
+ content: 'No active session in this thread',
130
+ ephemeral: true,
131
+ flags: SILENT_MESSAGE_FLAGS,
132
+ });
133
+ return;
134
+ }
135
+ const sessionId = row.session_id;
136
+ try {
137
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
138
+ const getClient = await initializeOpencodeForDirectory(directory);
139
+ // Check if session has reverted state
140
+ const sessionResponse = await getClient().session.get({
141
+ path: { id: sessionId },
142
+ });
143
+ if (!sessionResponse.data?.revert) {
144
+ await command.editReply('Nothing to redo - no previous undo found');
145
+ return;
146
+ }
147
+ const response = await getClient().session.unrevert({
148
+ path: { id: sessionId },
149
+ });
150
+ if (response.error) {
151
+ await command.editReply(`Failed to redo: ${JSON.stringify(response.error)}`);
152
+ return;
153
+ }
154
+ await command.editReply(`⏩ **Restored** - session back to previous state`);
155
+ logger.log(`Session ${sessionId} unrevert completed`);
156
+ }
157
+ catch (error) {
158
+ logger.error('[REDO] Error:', error);
159
+ await command.editReply(`Failed to redo: ${error instanceof Error ? error.message : 'Unknown error'}`);
160
+ }
161
+ }