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,111 @@
1
+ // /create-new-project command - Create a new project folder, initialize git, and start a session.
2
+
3
+ import { ChannelType, type TextChannel } from 'discord.js'
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import type { CommandContext } from './types.js'
8
+ import { createProjectChannels } from '../channel-management.js'
9
+ import { handleOpencodeSession } from '../session-handler.js'
10
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
11
+ import { createLogger } from '../logger.js'
12
+
13
+ const logger = createLogger('CREATE-NEW-PROJECT')
14
+
15
+ export async function handleCreateNewProjectCommand({
16
+ command,
17
+ appId,
18
+ }: CommandContext): Promise<void> {
19
+ await command.deferReply({ ephemeral: false })
20
+
21
+ const projectName = command.options.getString('name', true)
22
+ const guild = command.guild
23
+ const channel = command.channel
24
+
25
+ if (!guild) {
26
+ await command.editReply('This command can only be used in a guild')
27
+ return
28
+ }
29
+
30
+ if (!channel || channel.type !== ChannelType.GuildText) {
31
+ await command.editReply('This command can only be used in a text channel')
32
+ return
33
+ }
34
+
35
+ const sanitizedName = projectName
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9-]/g, '-')
38
+ .replace(/-+/g, '-')
39
+ .replace(/^-|-$/g, '')
40
+ .slice(0, 100)
41
+
42
+ if (!sanitizedName) {
43
+ await command.editReply('Invalid project name')
44
+ return
45
+ }
46
+
47
+ const kimakiDir = path.join(os.homedir(), 'kimaki')
48
+ const projectDirectory = path.join(kimakiDir, sanitizedName)
49
+
50
+ try {
51
+ if (!fs.existsSync(kimakiDir)) {
52
+ fs.mkdirSync(kimakiDir, { recursive: true })
53
+ logger.log(`Created kimaki directory: ${kimakiDir}`)
54
+ }
55
+
56
+ if (fs.existsSync(projectDirectory)) {
57
+ await command.editReply(
58
+ `Project directory already exists: ${projectDirectory}`,
59
+ )
60
+ return
61
+ }
62
+
63
+ fs.mkdirSync(projectDirectory, { recursive: true })
64
+ logger.log(`Created project directory: ${projectDirectory}`)
65
+
66
+ const { execSync } = await import('node:child_process')
67
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
68
+ logger.log(`Initialized git in: ${projectDirectory}`)
69
+
70
+ const { textChannelId, voiceChannelId, channelName } =
71
+ await createProjectChannels({
72
+ guild,
73
+ projectDirectory,
74
+ appId,
75
+ })
76
+
77
+ const textChannel = (await guild.channels.fetch(
78
+ textChannelId,
79
+ )) as TextChannel
80
+
81
+ await command.editReply(
82
+ `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
83
+ )
84
+
85
+ const starterMessage = await textChannel.send({
86
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
87
+ flags: SILENT_MESSAGE_FLAGS,
88
+ })
89
+
90
+ const thread = await starterMessage.startThread({
91
+ name: `Init: ${sanitizedName}`,
92
+ autoArchiveDuration: 1440,
93
+ reason: 'New project session',
94
+ })
95
+
96
+ await handleOpencodeSession({
97
+ prompt:
98
+ 'The project was just initialized. Say hi and ask what the user wants to build.',
99
+ thread,
100
+ projectDirectory,
101
+ channelId: textChannel.id,
102
+ })
103
+
104
+ logger.log(`Created new project ${channelName} at ${projectDirectory}`)
105
+ } catch (error) {
106
+ logger.error('[CREATE-NEW-PROJECT] Error:', error)
107
+ await command.editReply(
108
+ `Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
109
+ )
110
+ }
111
+ }
@@ -0,0 +1,257 @@
1
+ // /fork command - Fork the session from a past user message.
2
+
3
+ import {
4
+ ChatInputCommandInteraction,
5
+ StringSelectMenuInteraction,
6
+ StringSelectMenuBuilder,
7
+ ActionRowBuilder,
8
+ ChannelType,
9
+ ThreadAutoArchiveDuration,
10
+ type ThreadChannel,
11
+ } from 'discord.js'
12
+ import { getDatabase } from '../database.js'
13
+ import { initializeOpencodeForDirectory } from '../opencode.js'
14
+ import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
15
+ import { collectLastAssistantParts } from '../message-formatting.js'
16
+ import { createLogger } from '../logger.js'
17
+
18
+ const sessionLogger = createLogger('SESSION')
19
+ const forkLogger = createLogger('FORK')
20
+
21
+ export async function handleForkCommand(interaction: ChatInputCommandInteraction): Promise<void> {
22
+ const channel = interaction.channel
23
+
24
+ if (!channel) {
25
+ await interaction.reply({
26
+ content: 'This command can only be used in a channel',
27
+ ephemeral: true,
28
+ })
29
+ return
30
+ }
31
+
32
+ const isThread = [
33
+ ChannelType.PublicThread,
34
+ ChannelType.PrivateThread,
35
+ ChannelType.AnnouncementThread,
36
+ ].includes(channel.type)
37
+
38
+ if (!isThread) {
39
+ await interaction.reply({
40
+ content: 'This command can only be used in a thread with an active session',
41
+ ephemeral: true,
42
+ })
43
+ return
44
+ }
45
+
46
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
47
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
48
+
49
+ if (!directory) {
50
+ await interaction.reply({
51
+ content: 'Could not determine project directory for this channel',
52
+ ephemeral: true,
53
+ })
54
+ return
55
+ }
56
+
57
+ const row = getDatabase()
58
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
59
+ .get(channel.id) as { session_id: string } | undefined
60
+
61
+ if (!row?.session_id) {
62
+ await interaction.reply({
63
+ content: 'No active session in this thread',
64
+ ephemeral: true,
65
+ })
66
+ return
67
+ }
68
+
69
+ // Defer reply before API calls to avoid 3-second timeout
70
+ await interaction.deferReply({ ephemeral: true })
71
+
72
+ const sessionId = row.session_id
73
+
74
+ try {
75
+ const getClient = await initializeOpencodeForDirectory(directory)
76
+
77
+ const messagesResponse = await getClient().session.messages({
78
+ path: { id: sessionId },
79
+ })
80
+
81
+ if (!messagesResponse.data) {
82
+ await interaction.editReply({
83
+ content: 'Failed to fetch session messages',
84
+ })
85
+ return
86
+ }
87
+
88
+ const userMessages = messagesResponse.data.filter(
89
+ (m) => m.info.role === 'user'
90
+ )
91
+
92
+ if (userMessages.length === 0) {
93
+ await interaction.editReply({
94
+ content: 'No user messages found in this session',
95
+ })
96
+ return
97
+ }
98
+
99
+ const recentMessages = userMessages.slice(-25)
100
+
101
+ const options = recentMessages.map((m, index) => {
102
+ const textPart = m.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
103
+ const preview = textPart?.text?.slice(0, 80) || '(no text)'
104
+ const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
105
+
106
+ return {
107
+ label: label.slice(0, 100),
108
+ value: m.info.id,
109
+ description: new Date(m.info.time.created).toLocaleString().slice(0, 50),
110
+ }
111
+ })
112
+
113
+ const encodedDir = Buffer.from(directory).toString('base64')
114
+
115
+ const selectMenu = new StringSelectMenuBuilder()
116
+ .setCustomId(`fork_select:${sessionId}:${encodedDir}`)
117
+ .setPlaceholder('Select a message to fork from')
118
+ .addOptions(options)
119
+
120
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>()
121
+ .addComponents(selectMenu)
122
+
123
+ await interaction.editReply({
124
+ content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
125
+ components: [actionRow],
126
+ })
127
+ } catch (error) {
128
+ forkLogger.error('Error loading messages:', error)
129
+ await interaction.editReply({
130
+ content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
131
+ })
132
+ }
133
+ }
134
+
135
+ export async function handleForkSelectMenu(interaction: StringSelectMenuInteraction): Promise<void> {
136
+ const customId = interaction.customId
137
+
138
+ if (!customId.startsWith('fork_select:')) {
139
+ return
140
+ }
141
+
142
+ const [, sessionId, encodedDir] = customId.split(':')
143
+ if (!sessionId || !encodedDir) {
144
+ await interaction.reply({
145
+ content: 'Invalid selection data',
146
+ ephemeral: true,
147
+ })
148
+ return
149
+ }
150
+
151
+ const directory = Buffer.from(encodedDir, 'base64').toString('utf-8')
152
+ const selectedMessageId = interaction.values[0]
153
+
154
+ if (!selectedMessageId) {
155
+ await interaction.reply({
156
+ content: 'No message selected',
157
+ ephemeral: true,
158
+ })
159
+ return
160
+ }
161
+
162
+ await interaction.deferReply({ ephemeral: false })
163
+
164
+ try {
165
+ const getClient = await initializeOpencodeForDirectory(directory)
166
+
167
+ const forkResponse = await getClient().session.fork({
168
+ path: { id: sessionId },
169
+ body: { messageID: selectedMessageId },
170
+ })
171
+
172
+ if (!forkResponse.data) {
173
+ await interaction.editReply('Failed to fork session')
174
+ return
175
+ }
176
+
177
+ const forkedSession = forkResponse.data
178
+ const parentChannel = interaction.channel
179
+
180
+ if (!parentChannel || ![
181
+ ChannelType.PublicThread,
182
+ ChannelType.PrivateThread,
183
+ ChannelType.AnnouncementThread,
184
+ ].includes(parentChannel.type)) {
185
+ await interaction.editReply('Could not access parent channel')
186
+ return
187
+ }
188
+
189
+ const textChannel = await resolveTextChannel(parentChannel as ThreadChannel)
190
+
191
+ if (!textChannel) {
192
+ await interaction.editReply('Could not resolve parent text channel')
193
+ return
194
+ }
195
+
196
+ const thread = await textChannel.threads.create({
197
+ name: `Fork: ${forkedSession.title}`.slice(0, 100),
198
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
199
+ reason: `Forked from session ${sessionId}`,
200
+ })
201
+
202
+ getDatabase()
203
+ .prepare(
204
+ 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)'
205
+ )
206
+ .run(thread.id, forkedSession.id)
207
+
208
+ sessionLogger.log(
209
+ `Created forked session ${forkedSession.id} in thread ${thread.id}`
210
+ )
211
+
212
+ await sendThreadMessage(
213
+ thread,
214
+ `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\``,
215
+ )
216
+
217
+ // Fetch and display the last assistant messages from the forked session
218
+ const messagesResponse = await getClient().session.messages({
219
+ path: { id: forkedSession.id },
220
+ })
221
+
222
+ if (messagesResponse.data) {
223
+ const { partIds, content } = collectLastAssistantParts({
224
+ messages: messagesResponse.data,
225
+ })
226
+
227
+ if (content.trim()) {
228
+ const discordMessage = await sendThreadMessage(thread, content)
229
+
230
+ // Store part-message mappings for future reference
231
+ const stmt = getDatabase().prepare(
232
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
233
+ )
234
+ const transaction = getDatabase().transaction((ids: string[]) => {
235
+ for (const partId of ids) {
236
+ stmt.run(partId, discordMessage.id, thread.id)
237
+ }
238
+ })
239
+ transaction(partIds)
240
+ }
241
+ }
242
+
243
+ await sendThreadMessage(
244
+ thread,
245
+ `You can now continue the conversation from this point.`,
246
+ )
247
+
248
+ await interaction.editReply(
249
+ `Session forked! Continue in ${thread.toString()}`
250
+ )
251
+ } catch (error) {
252
+ forkLogger.error('Error forking session:', error)
253
+ await interaction.editReply(
254
+ `Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`
255
+ )
256
+ }
257
+ }