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,402 @@
1
+ // /model command - Set the preferred model for this channel or session.
2
+
3
+ import {
4
+ ChatInputCommandInteraction,
5
+ StringSelectMenuInteraction,
6
+ StringSelectMenuBuilder,
7
+ ActionRowBuilder,
8
+ ChannelType,
9
+ type ThreadChannel,
10
+ type TextChannel,
11
+ } from 'discord.js'
12
+ import crypto from 'node:crypto'
13
+ import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js'
14
+ import { initializeOpencodeForDirectory } from '../opencode.js'
15
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
+ import { abortAndRetrySession } from '../session-handler.js'
17
+ import { createLogger } from '../logger.js'
18
+
19
+ const modelLogger = createLogger('MODEL')
20
+
21
+ // Store context by hash to avoid customId length limits (Discord max: 100 chars)
22
+ const pendingModelContexts = new Map<string, {
23
+ dir: string
24
+ channelId: string
25
+ sessionId?: string
26
+ isThread: boolean
27
+ providerId?: string
28
+ providerName?: string
29
+ thread?: ThreadChannel
30
+ }>()
31
+
32
+ export type ProviderInfo = {
33
+ id: string
34
+ name: string
35
+ models: Record<
36
+ string,
37
+ {
38
+ id: string
39
+ name: string
40
+ release_date: string
41
+ }
42
+ >
43
+ }
44
+
45
+ /**
46
+ * Handle the /model slash command.
47
+ * Shows a select menu with available providers.
48
+ */
49
+ export async function handleModelCommand({
50
+ interaction,
51
+ appId,
52
+ }: {
53
+ interaction: ChatInputCommandInteraction
54
+ appId: string
55
+ }): Promise<void> {
56
+ modelLogger.log('[MODEL] handleModelCommand called')
57
+
58
+ // Defer reply immediately to avoid 3-second timeout
59
+ await interaction.deferReply({ ephemeral: true })
60
+ modelLogger.log('[MODEL] Deferred reply')
61
+
62
+ // Ensure migrations are run
63
+ runModelMigrations()
64
+
65
+ const channel = interaction.channel
66
+
67
+ if (!channel) {
68
+ await interaction.editReply({
69
+ content: 'This command can only be used in a channel',
70
+ })
71
+ return
72
+ }
73
+
74
+ // Determine if we're in a thread or text channel
75
+ const isThread = [
76
+ ChannelType.PublicThread,
77
+ ChannelType.PrivateThread,
78
+ ChannelType.AnnouncementThread,
79
+ ].includes(channel.type)
80
+
81
+ let projectDirectory: string | undefined
82
+ let channelAppId: string | undefined
83
+ let targetChannelId: string
84
+ let sessionId: string | undefined
85
+
86
+ if (isThread) {
87
+ const thread = channel as ThreadChannel
88
+ const textChannel = await resolveTextChannel(thread)
89
+ const metadata = getKimakiMetadata(textChannel)
90
+ projectDirectory = metadata.projectDirectory
91
+ channelAppId = metadata.channelAppId
92
+ targetChannelId = textChannel?.id || channel.id
93
+
94
+ // Get session ID for this thread
95
+ const row = getDatabase()
96
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
97
+ .get(thread.id) as { session_id: string } | undefined
98
+ sessionId = row?.session_id
99
+ } else if (channel.type === ChannelType.GuildText) {
100
+ const textChannel = channel as TextChannel
101
+ const metadata = getKimakiMetadata(textChannel)
102
+ projectDirectory = metadata.projectDirectory
103
+ channelAppId = metadata.channelAppId
104
+ targetChannelId = channel.id
105
+ } else {
106
+ await interaction.editReply({
107
+ content: 'This command can only be used in text channels or threads',
108
+ })
109
+ return
110
+ }
111
+
112
+ if (channelAppId && channelAppId !== appId) {
113
+ await interaction.editReply({
114
+ content: 'This channel is not configured for this bot',
115
+ })
116
+ return
117
+ }
118
+
119
+ if (!projectDirectory) {
120
+ await interaction.editReply({
121
+ content: 'This channel is not configured with a project directory',
122
+ })
123
+ return
124
+ }
125
+
126
+ try {
127
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
128
+
129
+ const providersResponse = await getClient().provider.list({
130
+ query: { directory: projectDirectory },
131
+ })
132
+
133
+ if (!providersResponse.data) {
134
+ await interaction.editReply({
135
+ content: 'Failed to fetch providers',
136
+ })
137
+ return
138
+ }
139
+
140
+ const { all: allProviders, connected } = providersResponse.data
141
+
142
+ // Filter to only connected providers (have credentials)
143
+ const availableProviders = allProviders.filter((p) => {
144
+ return connected.includes(p.id)
145
+ })
146
+
147
+ if (availableProviders.length === 0) {
148
+ await interaction.editReply({
149
+ content:
150
+ 'No providers with credentials found. Use `/connect` in OpenCode TUI to add provider credentials.',
151
+ })
152
+ return
153
+ }
154
+
155
+ // Store context with a short hash key to avoid customId length limits
156
+ const context = {
157
+ dir: projectDirectory,
158
+ channelId: targetChannelId,
159
+ sessionId: sessionId,
160
+ isThread: isThread,
161
+ thread: isThread ? (channel as ThreadChannel) : undefined,
162
+ }
163
+ const contextHash = crypto.randomBytes(8).toString('hex')
164
+ pendingModelContexts.set(contextHash, context)
165
+
166
+ const options = availableProviders.slice(0, 25).map((provider) => {
167
+ const modelCount = Object.keys(provider.models || {}).length
168
+ return {
169
+ label: provider.name.slice(0, 100),
170
+ value: provider.id,
171
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
172
+ }
173
+ })
174
+
175
+ const selectMenu = new StringSelectMenuBuilder()
176
+ .setCustomId(`model_provider:${contextHash}`)
177
+ .setPlaceholder('Select a provider')
178
+ .addOptions(options)
179
+
180
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
181
+
182
+ await interaction.editReply({
183
+ content: '**Set Model Preference**\nSelect a provider:',
184
+ components: [actionRow],
185
+ })
186
+ } catch (error) {
187
+ modelLogger.error('Error loading providers:', error)
188
+ await interaction.editReply({
189
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
190
+ })
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Handle the provider select menu interaction.
196
+ * Shows a second select menu with models for the chosen provider.
197
+ */
198
+ export async function handleProviderSelectMenu(
199
+ interaction: StringSelectMenuInteraction
200
+ ): Promise<void> {
201
+ const customId = interaction.customId
202
+
203
+ if (!customId.startsWith('model_provider:')) {
204
+ return
205
+ }
206
+
207
+ // Defer update immediately to avoid timeout
208
+ await interaction.deferUpdate()
209
+
210
+ const contextHash = customId.replace('model_provider:', '')
211
+ const context = pendingModelContexts.get(contextHash)
212
+
213
+ if (!context) {
214
+ await interaction.editReply({
215
+ content: 'Selection expired. Please run /model again.',
216
+ components: [],
217
+ })
218
+ return
219
+ }
220
+
221
+ const selectedProviderId = interaction.values[0]
222
+ if (!selectedProviderId) {
223
+ await interaction.editReply({
224
+ content: 'No provider selected',
225
+ components: [],
226
+ })
227
+ return
228
+ }
229
+
230
+ try {
231
+ const getClient = await initializeOpencodeForDirectory(context.dir)
232
+
233
+ const providersResponse = await getClient().provider.list({
234
+ query: { directory: context.dir },
235
+ })
236
+
237
+ if (!providersResponse.data) {
238
+ await interaction.editReply({
239
+ content: 'Failed to fetch providers',
240
+ components: [],
241
+ })
242
+ return
243
+ }
244
+
245
+ const provider = providersResponse.data.all.find((p) => p.id === selectedProviderId)
246
+
247
+ if (!provider) {
248
+ await interaction.editReply({
249
+ content: 'Provider not found',
250
+ components: [],
251
+ })
252
+ return
253
+ }
254
+
255
+ const models = Object.entries(provider.models || {})
256
+ .map(([modelId, model]) => ({
257
+ id: modelId,
258
+ name: model.name,
259
+ releaseDate: model.release_date,
260
+ }))
261
+ // Sort by release date descending (most recent first)
262
+ .sort((a, b) => {
263
+ const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0
264
+ const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0
265
+ return dateB - dateA
266
+ })
267
+
268
+ if (models.length === 0) {
269
+ await interaction.editReply({
270
+ content: `No models available for ${provider.name}`,
271
+ components: [],
272
+ })
273
+ return
274
+ }
275
+
276
+ // Take first 25 models (most recent since sorted descending)
277
+ const recentModels = models.slice(0, 25)
278
+
279
+ // Update context with provider info and reuse the same hash
280
+ context.providerId = selectedProviderId
281
+ context.providerName = provider.name
282
+ pendingModelContexts.set(contextHash, context)
283
+
284
+ const options = recentModels.map((model) => {
285
+ const dateStr = model.releaseDate
286
+ ? new Date(model.releaseDate).toLocaleDateString()
287
+ : 'Unknown date'
288
+ return {
289
+ label: model.name.slice(0, 100),
290
+ value: model.id,
291
+ description: dateStr.slice(0, 100),
292
+ }
293
+ })
294
+
295
+ const selectMenu = new StringSelectMenuBuilder()
296
+ .setCustomId(`model_select:${contextHash}`)
297
+ .setPlaceholder('Select a model')
298
+ .addOptions(options)
299
+
300
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
301
+
302
+ await interaction.editReply({
303
+ content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
304
+ components: [actionRow],
305
+ })
306
+ } catch (error) {
307
+ modelLogger.error('Error loading models:', error)
308
+ await interaction.editReply({
309
+ content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
310
+ components: [],
311
+ })
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Handle the model select menu interaction.
317
+ * Stores the model preference in the database.
318
+ */
319
+ export async function handleModelSelectMenu(
320
+ interaction: StringSelectMenuInteraction
321
+ ): Promise<void> {
322
+ const customId = interaction.customId
323
+
324
+ if (!customId.startsWith('model_select:')) {
325
+ return
326
+ }
327
+
328
+ // Defer update immediately
329
+ await interaction.deferUpdate()
330
+
331
+ const contextHash = customId.replace('model_select:', '')
332
+ const context = pendingModelContexts.get(contextHash)
333
+
334
+ if (!context || !context.providerId || !context.providerName) {
335
+ await interaction.editReply({
336
+ content: 'Selection expired. Please run /model again.',
337
+ components: [],
338
+ })
339
+ return
340
+ }
341
+
342
+ const selectedModelId = interaction.values[0]
343
+ if (!selectedModelId) {
344
+ await interaction.editReply({
345
+ content: 'No model selected',
346
+ components: [],
347
+ })
348
+ return
349
+ }
350
+
351
+ // Build full model ID: provider_id/model_id
352
+ const fullModelId = `${context.providerId}/${selectedModelId}`
353
+
354
+ try {
355
+ // Store in appropriate table based on context
356
+ if (context.isThread && context.sessionId) {
357
+ // Store for session
358
+ setSessionModel(context.sessionId, fullModelId)
359
+ modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`)
360
+
361
+ // Check if there's a running request and abort+retry with new model
362
+ let retried = false
363
+ if (context.thread) {
364
+ retried = await abortAndRetrySession({
365
+ sessionId: context.sessionId,
366
+ thread: context.thread,
367
+ projectDirectory: context.dir,
368
+ })
369
+ }
370
+
371
+ if (retried) {
372
+ await interaction.editReply({
373
+ content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
374
+ components: [],
375
+ })
376
+ } else {
377
+ await interaction.editReply({
378
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
379
+ components: [],
380
+ })
381
+ }
382
+ } else {
383
+ // Store for channel
384
+ setChannelModel(context.channelId, fullModelId)
385
+ modelLogger.log(`Set model ${fullModelId} for channel ${context.channelId}`)
386
+
387
+ await interaction.editReply({
388
+ content: `Model preference set for this channel:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\nAll new sessions in this channel will use this model.`,
389
+ components: [],
390
+ })
391
+ }
392
+
393
+ // Clean up the context from memory
394
+ pendingModelContexts.delete(contextHash)
395
+ } catch (error) {
396
+ modelLogger.error('Error saving model preference:', error)
397
+ await interaction.editReply({
398
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
399
+ components: [],
400
+ })
401
+ }
402
+ }
@@ -0,0 +1,146 @@
1
+ // Permission commands - /accept, /accept-always, /reject
2
+
3
+ import { ChannelType } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { initializeOpencodeForDirectory } from '../opencode.js'
6
+ import { pendingPermissions } from '../session-handler.js'
7
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const logger = createLogger('PERMISSIONS')
11
+
12
+ export async function handleAcceptCommand({
13
+ command,
14
+ }: CommandContext): Promise<void> {
15
+ const scope = command.commandName === 'accept-always' ? 'always' : 'once'
16
+ const channel = command.channel
17
+
18
+ if (!channel) {
19
+ await command.reply({
20
+ content: 'This command can only be used in a channel',
21
+ ephemeral: true,
22
+ flags: SILENT_MESSAGE_FLAGS,
23
+ })
24
+ return
25
+ }
26
+
27
+ const isThread = [
28
+ ChannelType.PublicThread,
29
+ ChannelType.PrivateThread,
30
+ ChannelType.AnnouncementThread,
31
+ ].includes(channel.type)
32
+
33
+ if (!isThread) {
34
+ await command.reply({
35
+ content: 'This command can only be used in a thread with an active session',
36
+ ephemeral: true,
37
+ flags: SILENT_MESSAGE_FLAGS,
38
+ })
39
+ return
40
+ }
41
+
42
+ const pending = pendingPermissions.get(channel.id)
43
+ if (!pending) {
44
+ await command.reply({
45
+ content: 'No pending permission request in this thread',
46
+ ephemeral: true,
47
+ flags: SILENT_MESSAGE_FLAGS,
48
+ })
49
+ return
50
+ }
51
+
52
+ try {
53
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
54
+ await getClient().postSessionIdPermissionsPermissionId({
55
+ path: {
56
+ id: pending.permission.sessionID,
57
+ permissionID: pending.permission.id,
58
+ },
59
+ body: {
60
+ response: scope,
61
+ },
62
+ })
63
+
64
+ pendingPermissions.delete(channel.id)
65
+ const msg =
66
+ scope === 'always'
67
+ ? `✅ Permission **accepted** (auto-approve similar requests)`
68
+ : `✅ Permission **accepted**`
69
+ await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS })
70
+ logger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`)
71
+ } catch (error) {
72
+ logger.error('[ACCEPT] Error:', error)
73
+ await command.reply({
74
+ content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
75
+ ephemeral: true,
76
+ flags: SILENT_MESSAGE_FLAGS,
77
+ })
78
+ }
79
+ }
80
+
81
+ export async function handleRejectCommand({
82
+ command,
83
+ }: CommandContext): Promise<void> {
84
+ const channel = command.channel
85
+
86
+ if (!channel) {
87
+ await command.reply({
88
+ content: 'This command can only be used in a channel',
89
+ ephemeral: true,
90
+ flags: SILENT_MESSAGE_FLAGS,
91
+ })
92
+ return
93
+ }
94
+
95
+ const isThread = [
96
+ ChannelType.PublicThread,
97
+ ChannelType.PrivateThread,
98
+ ChannelType.AnnouncementThread,
99
+ ].includes(channel.type)
100
+
101
+ if (!isThread) {
102
+ await command.reply({
103
+ content: 'This command can only be used in a thread with an active session',
104
+ ephemeral: true,
105
+ flags: SILENT_MESSAGE_FLAGS,
106
+ })
107
+ return
108
+ }
109
+
110
+ const pending = pendingPermissions.get(channel.id)
111
+ if (!pending) {
112
+ await command.reply({
113
+ content: 'No pending permission request in this thread',
114
+ ephemeral: true,
115
+ flags: SILENT_MESSAGE_FLAGS,
116
+ })
117
+ return
118
+ }
119
+
120
+ try {
121
+ const getClient = await initializeOpencodeForDirectory(pending.directory)
122
+ await getClient().postSessionIdPermissionsPermissionId({
123
+ path: {
124
+ id: pending.permission.sessionID,
125
+ permissionID: pending.permission.id,
126
+ },
127
+ body: {
128
+ response: 'reject',
129
+ },
130
+ })
131
+
132
+ pendingPermissions.delete(channel.id)
133
+ await command.reply({
134
+ content: `❌ Permission **rejected**`,
135
+ flags: SILENT_MESSAGE_FLAGS,
136
+ })
137
+ logger.log(`Permission ${pending.permission.id} rejected`)
138
+ } catch (error) {
139
+ logger.error('[REJECT] Error:', error)
140
+ await command.reply({
141
+ content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
142
+ ephemeral: true,
143
+ flags: SILENT_MESSAGE_FLAGS,
144
+ })
145
+ }
146
+ }