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,181 @@
1
+ // Queue commands - /queue, /clear-queue
2
+
3
+ import { ChannelType, type ThreadChannel } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { getDatabase } from '../database.js'
6
+ import {
7
+ resolveTextChannel,
8
+ getKimakiMetadata,
9
+ sendThreadMessage,
10
+ SILENT_MESSAGE_FLAGS,
11
+ } from '../discord-utils.js'
12
+ import {
13
+ handleOpencodeSession,
14
+ abortControllers,
15
+ addToQueue,
16
+ getQueueLength,
17
+ clearQueue,
18
+ } from '../session-handler.js'
19
+ import { createLogger } from '../logger.js'
20
+
21
+ const logger = createLogger('QUEUE')
22
+
23
+ export async function handleQueueCommand({
24
+ command,
25
+ }: CommandContext): Promise<void> {
26
+ const message = command.options.getString('message', true)
27
+ const channel = command.channel
28
+
29
+ if (!channel) {
30
+ await command.reply({
31
+ content: 'This command can only be used in a channel',
32
+ ephemeral: true,
33
+ flags: SILENT_MESSAGE_FLAGS,
34
+ })
35
+ return
36
+ }
37
+
38
+ const isThread = [
39
+ ChannelType.PublicThread,
40
+ ChannelType.PrivateThread,
41
+ ChannelType.AnnouncementThread,
42
+ ].includes(channel.type)
43
+
44
+ if (!isThread) {
45
+ await command.reply({
46
+ content: 'This command can only be used in a thread with an active session',
47
+ ephemeral: true,
48
+ flags: SILENT_MESSAGE_FLAGS,
49
+ })
50
+ return
51
+ }
52
+
53
+ const row = getDatabase()
54
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
55
+ .get(channel.id) as { session_id: string } | undefined
56
+
57
+ if (!row?.session_id) {
58
+ await command.reply({
59
+ content: 'No active session in this thread. Send a message directly instead.',
60
+ ephemeral: true,
61
+ flags: SILENT_MESSAGE_FLAGS,
62
+ })
63
+ return
64
+ }
65
+
66
+ // Check if there's an active request running
67
+ const hasActiveRequest = abortControllers.has(row.session_id)
68
+
69
+ if (!hasActiveRequest) {
70
+ // No active request, send immediately
71
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
72
+ const { projectDirectory } = getKimakiMetadata(textChannel)
73
+
74
+ if (!projectDirectory) {
75
+ await command.reply({
76
+ content: 'Could not determine project directory',
77
+ ephemeral: true,
78
+ flags: SILENT_MESSAGE_FLAGS,
79
+ })
80
+ return
81
+ }
82
+
83
+ await command.reply({
84
+ content: `» **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
85
+ flags: SILENT_MESSAGE_FLAGS,
86
+ })
87
+
88
+ logger.log(
89
+ `[QUEUE] No active request, sending immediately in thread ${channel.id}`,
90
+ )
91
+
92
+ handleOpencodeSession({
93
+ prompt: message,
94
+ thread: channel as ThreadChannel,
95
+ projectDirectory,
96
+ channelId: textChannel?.id || channel.id,
97
+ }).catch(async (e) => {
98
+ logger.error(`[QUEUE] Failed to send message:`, e)
99
+ const errorMsg = e instanceof Error ? e.message : String(e)
100
+ await sendThreadMessage(
101
+ channel as ThreadChannel,
102
+ `✗ Failed: ${errorMsg.slice(0, 200)}`,
103
+ )
104
+ })
105
+
106
+ return
107
+ }
108
+
109
+ // Add to queue
110
+ const queuePosition = addToQueue({
111
+ threadId: channel.id,
112
+ message: {
113
+ prompt: message,
114
+ userId: command.user.id,
115
+ username: command.user.displayName,
116
+ queuedAt: Date.now(),
117
+ },
118
+ })
119
+
120
+ await command.reply({
121
+ content: `✅ Message queued (position: ${queuePosition}). Will be sent after current response.`,
122
+ ephemeral: true,
123
+ flags: SILENT_MESSAGE_FLAGS,
124
+ })
125
+
126
+ logger.log(
127
+ `[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`,
128
+ )
129
+ }
130
+
131
+ export async function handleClearQueueCommand({
132
+ command,
133
+ }: CommandContext): Promise<void> {
134
+ const channel = command.channel
135
+
136
+ if (!channel) {
137
+ await command.reply({
138
+ content: 'This command can only be used in a channel',
139
+ ephemeral: true,
140
+ flags: SILENT_MESSAGE_FLAGS,
141
+ })
142
+ return
143
+ }
144
+
145
+ const isThread = [
146
+ ChannelType.PublicThread,
147
+ ChannelType.PrivateThread,
148
+ ChannelType.AnnouncementThread,
149
+ ].includes(channel.type)
150
+
151
+ if (!isThread) {
152
+ await command.reply({
153
+ content: 'This command can only be used in a thread',
154
+ ephemeral: true,
155
+ flags: SILENT_MESSAGE_FLAGS,
156
+ })
157
+ return
158
+ }
159
+
160
+ const queueLength = getQueueLength(channel.id)
161
+
162
+ if (queueLength === 0) {
163
+ await command.reply({
164
+ content: 'No messages in queue',
165
+ ephemeral: true,
166
+ flags: SILENT_MESSAGE_FLAGS,
167
+ })
168
+ return
169
+ }
170
+
171
+ clearQueue(channel.id)
172
+
173
+ await command.reply({
174
+ content: `🗑 Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
175
+ flags: SILENT_MESSAGE_FLAGS,
176
+ })
177
+
178
+ logger.log(
179
+ `[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`,
180
+ )
181
+ }
@@ -0,0 +1,230 @@
1
+ // /resume command - Resume an existing OpenCode session.
2
+
3
+ import {
4
+ ChannelType,
5
+ ThreadAutoArchiveDuration,
6
+ type TextChannel,
7
+ type ThreadChannel,
8
+ } from 'discord.js'
9
+ import fs from 'node:fs'
10
+ import type { CommandContext, AutocompleteContext } from './types.js'
11
+ import { getDatabase } from '../database.js'
12
+ import { initializeOpencodeForDirectory } from '../opencode.js'
13
+ import {
14
+ sendThreadMessage,
15
+ resolveTextChannel,
16
+ getKimakiMetadata,
17
+ } from '../discord-utils.js'
18
+ import { extractTagsArrays } from '../xml.js'
19
+ import { collectLastAssistantParts } from '../message-formatting.js'
20
+ import { createLogger } from '../logger.js'
21
+
22
+ const logger = createLogger('RESUME')
23
+
24
+ export async function handleResumeCommand({
25
+ command,
26
+ appId,
27
+ }: CommandContext): Promise<void> {
28
+ await command.deferReply({ ephemeral: false })
29
+
30
+ const sessionId = command.options.getString('session', true)
31
+ const channel = command.channel
32
+
33
+ if (!channel || channel.type !== ChannelType.GuildText) {
34
+ await command.editReply('This command can only be used in text channels')
35
+ return
36
+ }
37
+
38
+ const textChannel = channel as TextChannel
39
+
40
+ let projectDirectory: string | undefined
41
+ let channelAppId: string | undefined
42
+
43
+ if (textChannel.topic) {
44
+ const extracted = extractTagsArrays({
45
+ xml: textChannel.topic,
46
+ tags: ['kimaki.directory', 'kimaki.app'],
47
+ })
48
+
49
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
50
+ channelAppId = extracted['kimaki.app']?.[0]?.trim()
51
+ }
52
+
53
+ if (channelAppId && channelAppId !== appId) {
54
+ await command.editReply('This channel is not configured for this bot')
55
+ return
56
+ }
57
+
58
+ if (!projectDirectory) {
59
+ await command.editReply(
60
+ 'This channel is not configured with a project directory',
61
+ )
62
+ return
63
+ }
64
+
65
+ if (!fs.existsSync(projectDirectory)) {
66
+ await command.editReply(`Directory does not exist: ${projectDirectory}`)
67
+ return
68
+ }
69
+
70
+ try {
71
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
72
+
73
+ const sessionResponse = await getClient().session.get({
74
+ path: { id: sessionId },
75
+ })
76
+
77
+ if (!sessionResponse.data) {
78
+ await command.editReply('Session not found')
79
+ return
80
+ }
81
+
82
+ const sessionTitle = sessionResponse.data.title
83
+
84
+ const thread = await textChannel.threads.create({
85
+ name: `Resume: ${sessionTitle}`.slice(0, 100),
86
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
87
+ reason: `Resuming session ${sessionId}`,
88
+ })
89
+
90
+ getDatabase()
91
+ .prepare(
92
+ 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
93
+ )
94
+ .run(thread.id, sessionId)
95
+
96
+ logger.log(`[RESUME] Created thread ${thread.id} for session ${sessionId}`)
97
+
98
+ const messagesResponse = await getClient().session.messages({
99
+ path: { id: sessionId },
100
+ })
101
+
102
+ if (!messagesResponse.data) {
103
+ throw new Error('Failed to fetch session messages')
104
+ }
105
+
106
+ const messages = messagesResponse.data
107
+
108
+ await command.editReply(
109
+ `Resumed session "${sessionTitle}" in ${thread.toString()}`,
110
+ )
111
+
112
+ await sendThreadMessage(
113
+ thread,
114
+ `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
115
+ )
116
+
117
+ const { partIds, content, skippedCount } = collectLastAssistantParts({
118
+ messages,
119
+ })
120
+
121
+ if (skippedCount > 0) {
122
+ await sendThreadMessage(
123
+ thread,
124
+ `*Skipped ${skippedCount} older assistant parts...*`,
125
+ )
126
+ }
127
+
128
+ if (content.trim()) {
129
+ const discordMessage = await sendThreadMessage(thread, content)
130
+
131
+ const stmt = getDatabase().prepare(
132
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
133
+ )
134
+
135
+ const transaction = getDatabase().transaction((ids: string[]) => {
136
+ for (const partId of ids) {
137
+ stmt.run(partId, discordMessage.id, thread.id)
138
+ }
139
+ })
140
+
141
+ transaction(partIds)
142
+ }
143
+
144
+ const messageCount = messages.length
145
+
146
+ await sendThreadMessage(
147
+ thread,
148
+ `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
149
+ )
150
+ } catch (error) {
151
+ logger.error('[RESUME] Error:', error)
152
+ await command.editReply(
153
+ `Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`,
154
+ )
155
+ }
156
+ }
157
+
158
+ export async function handleResumeAutocomplete({
159
+ interaction,
160
+ appId,
161
+ }: AutocompleteContext): Promise<void> {
162
+ const focusedValue = interaction.options.getFocused()
163
+
164
+ let projectDirectory: string | undefined
165
+
166
+ if (interaction.channel) {
167
+ const textChannel = await resolveTextChannel(
168
+ interaction.channel as TextChannel | ThreadChannel | null,
169
+ )
170
+ if (textChannel) {
171
+ const { projectDirectory: directory, channelAppId } =
172
+ getKimakiMetadata(textChannel)
173
+ if (channelAppId && channelAppId !== appId) {
174
+ await interaction.respond([])
175
+ return
176
+ }
177
+ projectDirectory = directory
178
+ }
179
+ }
180
+
181
+ if (!projectDirectory) {
182
+ await interaction.respond([])
183
+ return
184
+ }
185
+
186
+ try {
187
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
188
+
189
+ const sessionsResponse = await getClient().session.list()
190
+ if (!sessionsResponse.data) {
191
+ await interaction.respond([])
192
+ return
193
+ }
194
+
195
+ const existingSessionIds = new Set(
196
+ (
197
+ getDatabase()
198
+ .prepare('SELECT session_id FROM thread_sessions')
199
+ .all() as { session_id: string }[]
200
+ ).map((row) => row.session_id),
201
+ )
202
+
203
+ const sessions = sessionsResponse.data
204
+ .filter((session) => !existingSessionIds.has(session.id))
205
+ .filter((session) =>
206
+ session.title.toLowerCase().includes(focusedValue.toLowerCase()),
207
+ )
208
+ .slice(0, 25)
209
+ .map((session) => {
210
+ const dateStr = new Date(session.time.updated).toLocaleString()
211
+ const suffix = ` (${dateStr})`
212
+ const maxTitleLength = 100 - suffix.length
213
+
214
+ let title = session.title
215
+ if (title.length > maxTitleLength) {
216
+ title = title.slice(0, Math.max(0, maxTitleLength - 1)) + '…'
217
+ }
218
+
219
+ return {
220
+ name: `${title}${suffix}`,
221
+ value: session.id,
222
+ }
223
+ })
224
+
225
+ await interaction.respond(sessions)
226
+ } catch (error) {
227
+ logger.error('[AUTOCOMPLETE] Error fetching sessions:', error)
228
+ await interaction.respond([])
229
+ }
230
+ }
@@ -0,0 +1,184 @@
1
+ // /session command - Start a new OpenCode session.
2
+
3
+ import { ChannelType, type TextChannel } from 'discord.js'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import type { CommandContext, AutocompleteContext } from './types.js'
7
+ import { getDatabase } from '../database.js'
8
+ import { initializeOpencodeForDirectory } from '../opencode.js'
9
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
10
+ import { extractTagsArrays } from '../xml.js'
11
+ import { handleOpencodeSession } from '../session-handler.js'
12
+ import { createLogger } from '../logger.js'
13
+
14
+ const logger = createLogger('SESSION')
15
+
16
+ export async function handleSessionCommand({
17
+ command,
18
+ appId,
19
+ }: CommandContext): Promise<void> {
20
+ await command.deferReply({ ephemeral: false })
21
+
22
+ const prompt = command.options.getString('prompt', true)
23
+ const filesString = command.options.getString('files') || ''
24
+ const channel = command.channel
25
+
26
+ if (!channel || channel.type !== ChannelType.GuildText) {
27
+ await command.editReply('This command can only be used in text channels')
28
+ return
29
+ }
30
+
31
+ const textChannel = channel as TextChannel
32
+
33
+ let projectDirectory: string | undefined
34
+ let channelAppId: string | undefined
35
+
36
+ if (textChannel.topic) {
37
+ const extracted = extractTagsArrays({
38
+ xml: textChannel.topic,
39
+ tags: ['kimaki.directory', 'kimaki.app'],
40
+ })
41
+
42
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
43
+ channelAppId = extracted['kimaki.app']?.[0]?.trim()
44
+ }
45
+
46
+ if (channelAppId && channelAppId !== appId) {
47
+ await command.editReply('This channel is not configured for this bot')
48
+ return
49
+ }
50
+
51
+ if (!projectDirectory) {
52
+ await command.editReply(
53
+ 'This channel is not configured with a project directory',
54
+ )
55
+ return
56
+ }
57
+
58
+ if (!fs.existsSync(projectDirectory)) {
59
+ await command.editReply(`Directory does not exist: ${projectDirectory}`)
60
+ return
61
+ }
62
+
63
+ try {
64
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
65
+
66
+ const files = filesString
67
+ .split(',')
68
+ .map((f) => f.trim())
69
+ .filter((f) => f)
70
+
71
+ let fullPrompt = prompt
72
+ if (files.length > 0) {
73
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`
74
+ }
75
+
76
+ const starterMessage = await textChannel.send({
77
+ content: `🚀 **Starting OpenCode session**\n📝 ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\n📎 Files: ${files.join(', ')}` : ''}`,
78
+ flags: SILENT_MESSAGE_FLAGS,
79
+ })
80
+
81
+ const thread = await starterMessage.startThread({
82
+ name: prompt.slice(0, 100),
83
+ autoArchiveDuration: 1440,
84
+ reason: 'OpenCode session',
85
+ })
86
+
87
+ await command.editReply(`Created new session in ${thread.toString()}`)
88
+
89
+ await handleOpencodeSession({
90
+ prompt: fullPrompt,
91
+ thread,
92
+ projectDirectory,
93
+ channelId: textChannel.id,
94
+ })
95
+ } catch (error) {
96
+ logger.error('[SESSION] Error:', error)
97
+ await command.editReply(
98
+ `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
99
+ )
100
+ }
101
+ }
102
+
103
+ export async function handleSessionAutocomplete({
104
+ interaction,
105
+ appId,
106
+ }: AutocompleteContext): Promise<void> {
107
+ const focusedOption = interaction.options.getFocused(true)
108
+
109
+ if (focusedOption.name !== 'files') {
110
+ return
111
+ }
112
+
113
+ const focusedValue = focusedOption.value
114
+
115
+ const parts = focusedValue.split(',')
116
+ const previousFiles = parts
117
+ .slice(0, -1)
118
+ .map((f) => f.trim())
119
+ .filter((f) => f)
120
+ const currentQuery = (parts[parts.length - 1] || '').trim()
121
+
122
+ let projectDirectory: string | undefined
123
+
124
+ if (interaction.channel) {
125
+ const channel = interaction.channel
126
+ if (channel.type === ChannelType.GuildText) {
127
+ const textChannel = channel as TextChannel
128
+ if (textChannel.topic) {
129
+ const extracted = extractTagsArrays({
130
+ xml: textChannel.topic,
131
+ tags: ['kimaki.directory', 'kimaki.app'],
132
+ })
133
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
134
+ if (channelAppId && channelAppId !== appId) {
135
+ await interaction.respond([])
136
+ return
137
+ }
138
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
139
+ }
140
+ }
141
+ }
142
+
143
+ if (!projectDirectory) {
144
+ await interaction.respond([])
145
+ return
146
+ }
147
+
148
+ try {
149
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
150
+
151
+ const response = await getClient().find.files({
152
+ query: {
153
+ query: currentQuery || '',
154
+ },
155
+ })
156
+
157
+ const files = response.data || []
158
+
159
+ const prefix =
160
+ previousFiles.length > 0 ? previousFiles.join(', ') + ', ' : ''
161
+
162
+ const choices = files
163
+ .map((file: string) => {
164
+ const fullValue = prefix + file
165
+ const allFiles = [...previousFiles, file]
166
+ const allBasenames = allFiles.map((f) => f.split('/').pop() || f)
167
+ let displayName = allBasenames.join(', ')
168
+ if (displayName.length > 100) {
169
+ displayName = '…' + displayName.slice(-97)
170
+ }
171
+ return {
172
+ name: displayName,
173
+ value: fullValue,
174
+ }
175
+ })
176
+ .filter((choice) => choice.value.length <= 100)
177
+ .slice(0, 25)
178
+
179
+ await interaction.respond(choices)
180
+ } catch (error) {
181
+ logger.error('[AUTOCOMPLETE] Error fetching files:', error)
182
+ await interaction.respond([])
183
+ }
184
+ }
@@ -0,0 +1,96 @@
1
+ // /share command - Share the current session as a public URL.
2
+
3
+ import { ChannelType, type ThreadChannel } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { getDatabase } from '../database.js'
6
+ import { initializeOpencodeForDirectory } from '../opencode.js'
7
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const logger = createLogger('SHARE')
11
+
12
+ export async function handleShareCommand({
13
+ command,
14
+ }: CommandContext): Promise<void> {
15
+ const channel = command.channel
16
+
17
+ if (!channel) {
18
+ await command.reply({
19
+ content: 'This command can only be used in a channel',
20
+ ephemeral: true,
21
+ flags: SILENT_MESSAGE_FLAGS,
22
+ })
23
+ return
24
+ }
25
+
26
+ const isThread = [
27
+ ChannelType.PublicThread,
28
+ ChannelType.PrivateThread,
29
+ ChannelType.AnnouncementThread,
30
+ ].includes(channel.type)
31
+
32
+ if (!isThread) {
33
+ await command.reply({
34
+ content: 'This command can only be used in a thread with an active session',
35
+ ephemeral: true,
36
+ flags: SILENT_MESSAGE_FLAGS,
37
+ })
38
+ return
39
+ }
40
+
41
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
42
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
43
+
44
+ if (!directory) {
45
+ await command.reply({
46
+ content: 'Could not determine project directory for this channel',
47
+ ephemeral: true,
48
+ flags: SILENT_MESSAGE_FLAGS,
49
+ })
50
+ return
51
+ }
52
+
53
+ const row = getDatabase()
54
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
55
+ .get(channel.id) as { session_id: string } | undefined
56
+
57
+ if (!row?.session_id) {
58
+ await command.reply({
59
+ content: 'No active session in this thread',
60
+ ephemeral: true,
61
+ flags: SILENT_MESSAGE_FLAGS,
62
+ })
63
+ return
64
+ }
65
+
66
+ const sessionId = row.session_id
67
+
68
+ try {
69
+ const getClient = await initializeOpencodeForDirectory(directory)
70
+ const response = await getClient().session.share({
71
+ path: { id: sessionId },
72
+ })
73
+
74
+ if (!response.data?.share?.url) {
75
+ await command.reply({
76
+ content: 'Failed to generate share URL',
77
+ ephemeral: true,
78
+ flags: SILENT_MESSAGE_FLAGS,
79
+ })
80
+ return
81
+ }
82
+
83
+ await command.reply({
84
+ content: `🔗 **Session shared:** ${response.data.share.url}`,
85
+ flags: SILENT_MESSAGE_FLAGS,
86
+ })
87
+ logger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
88
+ } catch (error) {
89
+ logger.error('[SHARE] Error:', error)
90
+ await command.reply({
91
+ content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
92
+ ephemeral: true,
93
+ flags: SILENT_MESSAGE_FLAGS,
94
+ })
95
+ }
96
+ }
@@ -0,0 +1,25 @@
1
+ // Shared types for command handlers.
2
+
3
+ import type {
4
+ AutocompleteInteraction,
5
+ ChatInputCommandInteraction,
6
+ StringSelectMenuInteraction,
7
+ } from 'discord.js'
8
+
9
+ export type CommandContext = {
10
+ command: ChatInputCommandInteraction
11
+ appId: string
12
+ }
13
+
14
+ export type CommandHandler = (ctx: CommandContext) => Promise<void>
15
+
16
+ export type AutocompleteContext = {
17
+ interaction: AutocompleteInteraction
18
+ appId: string
19
+ }
20
+
21
+ export type AutocompleteHandler = (ctx: AutocompleteContext) => Promise<void>
22
+
23
+ export type SelectMenuHandler = (
24
+ interaction: StringSelectMenuInteraction,
25
+ ) => Promise<void>