kimaki 0.4.23 → 0.4.25

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/bin.js +6 -1
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +3 -0
  5. package/dist/cli.js +93 -14
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +97 -0
  8. package/dist/commands/create-new-project.js +78 -0
  9. package/dist/commands/fork.js +186 -0
  10. package/dist/commands/model.js +294 -0
  11. package/dist/commands/permissions.js +126 -0
  12. package/dist/commands/queue.js +129 -0
  13. package/dist/commands/resume.js +145 -0
  14. package/dist/commands/session.js +144 -0
  15. package/dist/commands/share.js +80 -0
  16. package/dist/commands/types.js +2 -0
  17. package/dist/commands/undo-redo.js +161 -0
  18. package/dist/database.js +3 -0
  19. package/dist/discord-bot.js +3 -0
  20. package/dist/discord-utils.js +10 -1
  21. package/dist/format-tables.js +3 -0
  22. package/dist/genai-worker-wrapper.js +3 -0
  23. package/dist/genai-worker.js +3 -0
  24. package/dist/genai.js +3 -0
  25. package/dist/interaction-handler.js +71 -697
  26. package/dist/logger.js +3 -0
  27. package/dist/markdown.js +3 -0
  28. package/dist/message-formatting.js +41 -6
  29. package/dist/opencode.js +3 -0
  30. package/dist/session-handler.js +47 -3
  31. package/dist/system-message.js +16 -0
  32. package/dist/tools.js +3 -0
  33. package/dist/utils.js +3 -0
  34. package/dist/voice-handler.js +3 -0
  35. package/dist/voice.js +3 -0
  36. package/dist/worker-types.js +3 -0
  37. package/dist/xml.js +3 -0
  38. package/package.json +11 -12
  39. package/src/ai-tool-to-genai.ts +4 -0
  40. package/src/channel-management.ts +4 -0
  41. package/src/cli.ts +93 -14
  42. package/src/commands/abort.ts +94 -0
  43. package/src/commands/add-project.ts +138 -0
  44. package/src/commands/create-new-project.ts +111 -0
  45. package/src/{fork.ts → commands/fork.ts} +39 -5
  46. package/src/{model-command.ts → commands/model.ts} +7 -5
  47. package/src/commands/permissions.ts +146 -0
  48. package/src/commands/queue.ts +181 -0
  49. package/src/commands/resume.ts +230 -0
  50. package/src/commands/session.ts +186 -0
  51. package/src/commands/share.ts +96 -0
  52. package/src/commands/types.ts +25 -0
  53. package/src/commands/undo-redo.ts +213 -0
  54. package/src/database.ts +4 -0
  55. package/src/discord-bot.ts +4 -0
  56. package/src/discord-utils.ts +12 -0
  57. package/src/format-tables.ts +4 -0
  58. package/src/genai-worker-wrapper.ts +4 -0
  59. package/src/genai-worker.ts +4 -0
  60. package/src/genai.ts +4 -0
  61. package/src/interaction-handler.ts +81 -919
  62. package/src/logger.ts +4 -0
  63. package/src/markdown.ts +4 -0
  64. package/src/message-formatting.ts +52 -7
  65. package/src/opencode.ts +4 -0
  66. package/src/session-handler.ts +70 -3
  67. package/src/system-message.ts +17 -0
  68. package/src/tools.ts +4 -0
  69. package/src/utils.ts +4 -0
  70. package/src/voice-handler.ts +4 -0
  71. package/src/voice.ts +4 -0
  72. package/src/worker-types.ts +4 -0
  73. package/src/xml.ts +4 -0
  74. package/README.md +0 -48
@@ -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,186 @@
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, parseSlashCommand } 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
+ const parsedCommand = parseSlashCommand(fullPrompt)
90
+ await handleOpencodeSession({
91
+ prompt: fullPrompt,
92
+ thread,
93
+ projectDirectory,
94
+ parsedCommand,
95
+ channelId: textChannel.id,
96
+ })
97
+ } catch (error) {
98
+ logger.error('[SESSION] Error:', error)
99
+ await command.editReply(
100
+ `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
101
+ )
102
+ }
103
+ }
104
+
105
+ export async function handleSessionAutocomplete({
106
+ interaction,
107
+ appId,
108
+ }: AutocompleteContext): Promise<void> {
109
+ const focusedOption = interaction.options.getFocused(true)
110
+
111
+ if (focusedOption.name !== 'files') {
112
+ return
113
+ }
114
+
115
+ const focusedValue = focusedOption.value
116
+
117
+ const parts = focusedValue.split(',')
118
+ const previousFiles = parts
119
+ .slice(0, -1)
120
+ .map((f) => f.trim())
121
+ .filter((f) => f)
122
+ const currentQuery = (parts[parts.length - 1] || '').trim()
123
+
124
+ let projectDirectory: string | undefined
125
+
126
+ if (interaction.channel) {
127
+ const channel = interaction.channel
128
+ if (channel.type === ChannelType.GuildText) {
129
+ const textChannel = channel as TextChannel
130
+ if (textChannel.topic) {
131
+ const extracted = extractTagsArrays({
132
+ xml: textChannel.topic,
133
+ tags: ['kimaki.directory', 'kimaki.app'],
134
+ })
135
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim()
136
+ if (channelAppId && channelAppId !== appId) {
137
+ await interaction.respond([])
138
+ return
139
+ }
140
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
141
+ }
142
+ }
143
+ }
144
+
145
+ if (!projectDirectory) {
146
+ await interaction.respond([])
147
+ return
148
+ }
149
+
150
+ try {
151
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
152
+
153
+ const response = await getClient().find.files({
154
+ query: {
155
+ query: currentQuery || '',
156
+ },
157
+ })
158
+
159
+ const files = response.data || []
160
+
161
+ const prefix =
162
+ previousFiles.length > 0 ? previousFiles.join(', ') + ', ' : ''
163
+
164
+ const choices = files
165
+ .map((file: string) => {
166
+ const fullValue = prefix + file
167
+ const allFiles = [...previousFiles, file]
168
+ const allBasenames = allFiles.map((f) => f.split('/').pop() || f)
169
+ let displayName = allBasenames.join(', ')
170
+ if (displayName.length > 100) {
171
+ displayName = '…' + displayName.slice(-97)
172
+ }
173
+ return {
174
+ name: displayName,
175
+ value: fullValue,
176
+ }
177
+ })
178
+ .filter((choice) => choice.value.length <= 100)
179
+ .slice(0, 25)
180
+
181
+ await interaction.respond(choices)
182
+ } catch (error) {
183
+ logger.error('[AUTOCOMPLETE] Error fetching files:', error)
184
+ await interaction.respond([])
185
+ }
186
+ }
@@ -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>