kimaki 0.4.22 → 0.4.23

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.
@@ -1,6 +1,6 @@
1
1
  import { test, expect } from 'vitest'
2
2
  import { Lexer } from 'marked'
3
- import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discordBot.js'
3
+ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
4
4
 
5
5
 
6
6
 
package/src/fork.ts ADDED
@@ -0,0 +1,224 @@
1
+ import {
2
+ ChatInputCommandInteraction,
3
+ StringSelectMenuInteraction,
4
+ StringSelectMenuBuilder,
5
+ ActionRowBuilder,
6
+ ChannelType,
7
+ ThreadAutoArchiveDuration,
8
+ type ThreadChannel,
9
+ } from 'discord.js'
10
+ import type { TextPart } from '@opencode-ai/sdk'
11
+ import { getDatabase } from './database.js'
12
+ import { initializeOpencodeForDirectory } from './opencode.js'
13
+ import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from './discord-utils.js'
14
+ import { createLogger } from './logger.js'
15
+
16
+ const sessionLogger = createLogger('SESSION')
17
+ const forkLogger = createLogger('FORK')
18
+
19
+ export async function handleForkCommand(interaction: ChatInputCommandInteraction): Promise<void> {
20
+ const channel = interaction.channel
21
+
22
+ if (!channel) {
23
+ await interaction.reply({
24
+ content: 'This command can only be used in a channel',
25
+ ephemeral: true,
26
+ })
27
+ return
28
+ }
29
+
30
+ const isThread = [
31
+ ChannelType.PublicThread,
32
+ ChannelType.PrivateThread,
33
+ ChannelType.AnnouncementThread,
34
+ ].includes(channel.type)
35
+
36
+ if (!isThread) {
37
+ await interaction.reply({
38
+ content: 'This command can only be used in a thread with an active session',
39
+ ephemeral: true,
40
+ })
41
+ return
42
+ }
43
+
44
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
45
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
46
+
47
+ if (!directory) {
48
+ await interaction.reply({
49
+ content: 'Could not determine project directory for this channel',
50
+ ephemeral: true,
51
+ })
52
+ return
53
+ }
54
+
55
+ const row = getDatabase()
56
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
57
+ .get(channel.id) as { session_id: string } | undefined
58
+
59
+ if (!row?.session_id) {
60
+ await interaction.reply({
61
+ content: 'No active session in this thread',
62
+ ephemeral: true,
63
+ })
64
+ return
65
+ }
66
+
67
+ // Defer reply before API calls to avoid 3-second timeout
68
+ await interaction.deferReply({ ephemeral: true })
69
+
70
+ const sessionId = row.session_id
71
+
72
+ try {
73
+ const getClient = await initializeOpencodeForDirectory(directory)
74
+
75
+ const messagesResponse = await getClient().session.messages({
76
+ path: { id: sessionId },
77
+ })
78
+
79
+ if (!messagesResponse.data) {
80
+ await interaction.editReply({
81
+ content: 'Failed to fetch session messages',
82
+ })
83
+ return
84
+ }
85
+
86
+ const userMessages = messagesResponse.data.filter(
87
+ (m) => m.info.role === 'user'
88
+ )
89
+
90
+ if (userMessages.length === 0) {
91
+ await interaction.editReply({
92
+ content: 'No user messages found in this session',
93
+ })
94
+ return
95
+ }
96
+
97
+ const recentMessages = userMessages.slice(-25)
98
+
99
+ const options = recentMessages.map((m, index) => {
100
+ const textPart = m.parts.find((p) => p.type === 'text') as TextPart | undefined
101
+ const preview = textPart?.text?.slice(0, 80) || '(no text)'
102
+ const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
103
+
104
+ return {
105
+ label: label.slice(0, 100),
106
+ value: m.info.id,
107
+ description: new Date(m.info.time.created).toLocaleString().slice(0, 50),
108
+ }
109
+ })
110
+
111
+ const encodedDir = Buffer.from(directory).toString('base64')
112
+
113
+ const selectMenu = new StringSelectMenuBuilder()
114
+ .setCustomId(`fork_select:${sessionId}:${encodedDir}`)
115
+ .setPlaceholder('Select a message to fork from')
116
+ .addOptions(options)
117
+
118
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>()
119
+ .addComponents(selectMenu)
120
+
121
+ await interaction.editReply({
122
+ content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
123
+ components: [actionRow],
124
+ })
125
+ } catch (error) {
126
+ forkLogger.error('Error loading messages:', error)
127
+ await interaction.editReply({
128
+ content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
129
+ })
130
+ }
131
+ }
132
+
133
+ export async function handleForkSelectMenu(interaction: StringSelectMenuInteraction): Promise<void> {
134
+ const customId = interaction.customId
135
+
136
+ if (!customId.startsWith('fork_select:')) {
137
+ return
138
+ }
139
+
140
+ const [, sessionId, encodedDir] = customId.split(':')
141
+ if (!sessionId || !encodedDir) {
142
+ await interaction.reply({
143
+ content: 'Invalid selection data',
144
+ ephemeral: true,
145
+ })
146
+ return
147
+ }
148
+
149
+ const directory = Buffer.from(encodedDir, 'base64').toString('utf-8')
150
+ const selectedMessageId = interaction.values[0]
151
+
152
+ if (!selectedMessageId) {
153
+ await interaction.reply({
154
+ content: 'No message selected',
155
+ ephemeral: true,
156
+ })
157
+ return
158
+ }
159
+
160
+ await interaction.deferReply({ ephemeral: false })
161
+
162
+ try {
163
+ const getClient = await initializeOpencodeForDirectory(directory)
164
+
165
+ const forkResponse = await getClient().session.fork({
166
+ path: { id: sessionId },
167
+ body: { messageID: selectedMessageId },
168
+ })
169
+
170
+ if (!forkResponse.data) {
171
+ await interaction.editReply('Failed to fork session')
172
+ return
173
+ }
174
+
175
+ const forkedSession = forkResponse.data
176
+ const parentChannel = interaction.channel
177
+
178
+ if (!parentChannel || ![
179
+ ChannelType.PublicThread,
180
+ ChannelType.PrivateThread,
181
+ ChannelType.AnnouncementThread,
182
+ ].includes(parentChannel.type)) {
183
+ await interaction.editReply('Could not access parent channel')
184
+ return
185
+ }
186
+
187
+ const textChannel = await resolveTextChannel(parentChannel as ThreadChannel)
188
+
189
+ if (!textChannel) {
190
+ await interaction.editReply('Could not resolve parent text channel')
191
+ return
192
+ }
193
+
194
+ const thread = await textChannel.threads.create({
195
+ name: `Fork: ${forkedSession.title}`.slice(0, 100),
196
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
197
+ reason: `Forked from session ${sessionId}`,
198
+ })
199
+
200
+ getDatabase()
201
+ .prepare(
202
+ 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)'
203
+ )
204
+ .run(thread.id, forkedSession.id)
205
+
206
+ sessionLogger.log(
207
+ `Created forked session ${forkedSession.id} in thread ${thread.id}`
208
+ )
209
+
210
+ await sendThreadMessage(
211
+ thread,
212
+ `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\`\n\nYou can now continue the conversation from this point.`
213
+ )
214
+
215
+ await interaction.editReply(
216
+ `Session forked! Continue in ${thread.toString()}`
217
+ )
218
+ } catch (error) {
219
+ forkLogger.error('Error forking session:', error)
220
+ await interaction.editReply(
221
+ `Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`
222
+ )
223
+ }
224
+ }