kimaki 0.4.24 → 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.
- package/bin.js +6 -1
- package/dist/acp-client.test.js +149 -0
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +14 -9
- package/dist/cli.js +148 -17
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +54 -0
- package/dist/discord-bot.js +35 -32
- package/dist/discord-utils.js +81 -15
- package/dist/format-tables.js +3 -0
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +3 -0
- package/dist/genai.js +3 -0
- package/dist/interaction-handler.js +89 -695
- package/dist/logger.js +46 -5
- package/dist/markdown.js +107 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +113 -28
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +73 -16
- package/dist/session-handler.js +176 -63
- package/dist/system-message.js +7 -38
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +21 -8
- package/dist/voice.js +31 -12
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +3 -3
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.ts +4 -0
- package/src/channel-management.ts +24 -8
- package/src/cli.ts +163 -18
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/{fork.ts → commands/fork.ts} +40 -7
- package/src/{model-command.ts → commands/model.ts} +31 -9
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +40 -33
- package/src/discord-utils.ts +88 -14
- package/src/format-tables.ts +4 -0
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +4 -0
- package/src/genai.ts +4 -0
- package/src/interaction-handler.ts +111 -924
- package/src/logger.ts +51 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +136 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +143 -30
- package/src/opencode.ts +84 -21
- package/src/session-handler.ts +248 -91
- package/src/system-message.ts +8 -38
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +24 -9
- package/src/voice.ts +36 -13
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Undo/Redo commands - /undo, /redo
|
|
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('UNDO-REDO')
|
|
11
|
+
|
|
12
|
+
export async function handleUndoCommand({
|
|
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
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
70
|
+
|
|
71
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
72
|
+
|
|
73
|
+
// Fetch messages to find the last assistant message
|
|
74
|
+
const messagesResponse = await getClient().session.messages({
|
|
75
|
+
path: { id: sessionId },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (!messagesResponse.data || messagesResponse.data.length === 0) {
|
|
79
|
+
await command.editReply('No messages to undo')
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Find the last assistant message
|
|
84
|
+
const lastAssistantMessage = [...messagesResponse.data]
|
|
85
|
+
.reverse()
|
|
86
|
+
.find((m) => m.info.role === 'assistant')
|
|
87
|
+
|
|
88
|
+
if (!lastAssistantMessage) {
|
|
89
|
+
await command.editReply('No assistant message to undo')
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const response = await getClient().session.revert({
|
|
94
|
+
path: { id: sessionId },
|
|
95
|
+
body: { messageID: lastAssistantMessage.info.id },
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
if (response.error) {
|
|
99
|
+
await command.editReply(
|
|
100
|
+
`Failed to undo: ${JSON.stringify(response.error)}`,
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const diffInfo = response.data?.revert?.diff
|
|
106
|
+
? `\n\`\`\`diff\n${response.data.revert.diff.slice(0, 1500)}\n\`\`\``
|
|
107
|
+
: ''
|
|
108
|
+
|
|
109
|
+
await command.editReply(
|
|
110
|
+
`⏪ **Undone** - reverted last assistant message${diffInfo}`,
|
|
111
|
+
)
|
|
112
|
+
logger.log(
|
|
113
|
+
`Session ${sessionId} reverted message ${lastAssistantMessage.info.id}`,
|
|
114
|
+
)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
logger.error('[UNDO] Error:', error)
|
|
117
|
+
await command.editReply(
|
|
118
|
+
`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function handleRedoCommand({
|
|
124
|
+
command,
|
|
125
|
+
}: CommandContext): Promise<void> {
|
|
126
|
+
const channel = command.channel
|
|
127
|
+
|
|
128
|
+
if (!channel) {
|
|
129
|
+
await command.reply({
|
|
130
|
+
content: 'This command can only be used in a channel',
|
|
131
|
+
ephemeral: true,
|
|
132
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
133
|
+
})
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const isThread = [
|
|
138
|
+
ChannelType.PublicThread,
|
|
139
|
+
ChannelType.PrivateThread,
|
|
140
|
+
ChannelType.AnnouncementThread,
|
|
141
|
+
].includes(channel.type)
|
|
142
|
+
|
|
143
|
+
if (!isThread) {
|
|
144
|
+
await command.reply({
|
|
145
|
+
content: 'This command can only be used in a thread with an active session',
|
|
146
|
+
ephemeral: true,
|
|
147
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
148
|
+
})
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
153
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
154
|
+
|
|
155
|
+
if (!directory) {
|
|
156
|
+
await command.reply({
|
|
157
|
+
content: 'Could not determine project directory for this channel',
|
|
158
|
+
ephemeral: true,
|
|
159
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
160
|
+
})
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const row = getDatabase()
|
|
165
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
166
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
167
|
+
|
|
168
|
+
if (!row?.session_id) {
|
|
169
|
+
await command.reply({
|
|
170
|
+
content: 'No active session in this thread',
|
|
171
|
+
ephemeral: true,
|
|
172
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
173
|
+
})
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sessionId = row.session_id
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
181
|
+
|
|
182
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
183
|
+
|
|
184
|
+
// Check if session has reverted state
|
|
185
|
+
const sessionResponse = await getClient().session.get({
|
|
186
|
+
path: { id: sessionId },
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
if (!sessionResponse.data?.revert) {
|
|
190
|
+
await command.editReply('Nothing to redo - no previous undo found')
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const response = await getClient().session.unrevert({
|
|
195
|
+
path: { id: sessionId },
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if (response.error) {
|
|
199
|
+
await command.editReply(
|
|
200
|
+
`Failed to redo: ${JSON.stringify(response.error)}`,
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await command.editReply(`⏩ **Restored** - session back to previous state`)
|
|
206
|
+
logger.log(`Session ${sessionId} unrevert completed`)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
logger.error('[REDO] Error:', error)
|
|
209
|
+
await command.editReply(
|
|
210
|
+
`Failed to redo: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// User-defined OpenCode command handler.
|
|
2
|
+
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
|
+
|
|
4
|
+
import type { CommandContext, CommandHandler } from './types.js'
|
|
5
|
+
import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
|
|
6
|
+
import { extractTagsArrays } from '../xml.js'
|
|
7
|
+
import { handleOpencodeSession } from '../session-handler.js'
|
|
8
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
9
|
+
import { createLogger } from '../logger.js'
|
|
10
|
+
import { getDatabase } from '../database.js'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
|
|
13
|
+
const userCommandLogger = createLogger('USER_CMD')
|
|
14
|
+
|
|
15
|
+
export const handleUserCommand: CommandHandler = async ({
|
|
16
|
+
command,
|
|
17
|
+
appId,
|
|
18
|
+
}: CommandContext) => {
|
|
19
|
+
const discordCommandName = command.commandName
|
|
20
|
+
// Strip the -cmd suffix to get the actual OpenCode command name
|
|
21
|
+
const commandName = discordCommandName.replace(/-cmd$/, '')
|
|
22
|
+
const args = command.options.getString('arguments') || ''
|
|
23
|
+
|
|
24
|
+
userCommandLogger.log(
|
|
25
|
+
`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const channel = command.channel
|
|
29
|
+
|
|
30
|
+
userCommandLogger.log(
|
|
31
|
+
`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const isThread = channel && [
|
|
35
|
+
ChannelType.PublicThread,
|
|
36
|
+
ChannelType.PrivateThread,
|
|
37
|
+
ChannelType.AnnouncementThread,
|
|
38
|
+
].includes(channel.type)
|
|
39
|
+
|
|
40
|
+
const isTextChannel = channel?.type === ChannelType.GuildText
|
|
41
|
+
|
|
42
|
+
if (!channel || (!isTextChannel && !isThread)) {
|
|
43
|
+
await command.reply({
|
|
44
|
+
content: 'This command can only be used in text channels or threads',
|
|
45
|
+
ephemeral: true,
|
|
46
|
+
})
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let projectDirectory: string | undefined
|
|
51
|
+
let channelAppId: string | undefined
|
|
52
|
+
let textChannel: TextChannel | null = null
|
|
53
|
+
let thread: ThreadChannel | null = null
|
|
54
|
+
|
|
55
|
+
if (isThread) {
|
|
56
|
+
// Running in an existing thread - get project directory from parent channel
|
|
57
|
+
thread = channel as ThreadChannel
|
|
58
|
+
textChannel = thread.parent as TextChannel | null
|
|
59
|
+
|
|
60
|
+
// Verify this thread has an existing session
|
|
61
|
+
const row = getDatabase()
|
|
62
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
63
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
64
|
+
|
|
65
|
+
if (!row) {
|
|
66
|
+
await command.reply({
|
|
67
|
+
content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
|
|
68
|
+
ephemeral: true,
|
|
69
|
+
})
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (textChannel?.topic) {
|
|
74
|
+
const extracted = extractTagsArrays({
|
|
75
|
+
xml: textChannel.topic,
|
|
76
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
80
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Running in a text channel - will create a new thread
|
|
84
|
+
textChannel = channel as TextChannel
|
|
85
|
+
|
|
86
|
+
if (textChannel.topic) {
|
|
87
|
+
const extracted = extractTagsArrays({
|
|
88
|
+
xml: textChannel.topic,
|
|
89
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
93
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (channelAppId && channelAppId !== appId) {
|
|
98
|
+
await command.reply({
|
|
99
|
+
content: 'This channel is not configured for this bot',
|
|
100
|
+
ephemeral: true,
|
|
101
|
+
})
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!projectDirectory) {
|
|
106
|
+
await command.reply({
|
|
107
|
+
content: 'This channel is not configured with a project directory',
|
|
108
|
+
ephemeral: true,
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
114
|
+
await command.reply({
|
|
115
|
+
content: `Directory does not exist: ${projectDirectory}`,
|
|
116
|
+
ephemeral: true,
|
|
117
|
+
})
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await command.deferReply({ ephemeral: false })
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// Use the dedicated session.command API instead of formatting as text prompt
|
|
125
|
+
const commandPayload = { name: commandName, arguments: args }
|
|
126
|
+
|
|
127
|
+
if (isThread && thread) {
|
|
128
|
+
// Running in existing thread - just send the command
|
|
129
|
+
await command.editReply(`Running /${commandName}...`)
|
|
130
|
+
|
|
131
|
+
await handleOpencodeSession({
|
|
132
|
+
prompt: '', // Not used when command is set
|
|
133
|
+
thread,
|
|
134
|
+
projectDirectory,
|
|
135
|
+
channelId: textChannel?.id,
|
|
136
|
+
command: commandPayload,
|
|
137
|
+
})
|
|
138
|
+
} else if (textChannel) {
|
|
139
|
+
// Running in text channel - create a new thread
|
|
140
|
+
const starterMessage = await textChannel.send({
|
|
141
|
+
content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
|
|
142
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`
|
|
146
|
+
const newThread = await starterMessage.startThread({
|
|
147
|
+
name: threadName.slice(0, 100),
|
|
148
|
+
autoArchiveDuration: 1440,
|
|
149
|
+
reason: `OpenCode command: ${commandName}`,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await command.editReply(`Started /${commandName} in ${newThread.toString()}`)
|
|
153
|
+
|
|
154
|
+
await handleOpencodeSession({
|
|
155
|
+
prompt: '', // Not used when command is set
|
|
156
|
+
thread: newThread,
|
|
157
|
+
projectDirectory,
|
|
158
|
+
channelId: textChannel.id,
|
|
159
|
+
command: commandPayload,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
userCommandLogger.error(`Error executing /${commandName}:`, error)
|
|
164
|
+
|
|
165
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
166
|
+
|
|
167
|
+
if (command.deferred) {
|
|
168
|
+
await command.editReply({
|
|
169
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
170
|
+
})
|
|
171
|
+
} else {
|
|
172
|
+
await command.reply({
|
|
173
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
174
|
+
ephemeral: true,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// SQLite database manager for persistent bot state.
|
|
2
|
+
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
|
+
// API keys, and model preferences in ~/.kimaki/discord-sessions.db.
|
|
4
|
+
|
|
1
5
|
import Database from 'better-sqlite3'
|
|
2
6
|
import fs from 'node:fs'
|
|
3
7
|
import os from 'node:os'
|
|
@@ -96,6 +100,23 @@ export function runModelMigrations(database?: Database.Database): void {
|
|
|
96
100
|
)
|
|
97
101
|
`)
|
|
98
102
|
|
|
103
|
+
targetDb.exec(`
|
|
104
|
+
CREATE TABLE IF NOT EXISTS channel_agents (
|
|
105
|
+
channel_id TEXT PRIMARY KEY,
|
|
106
|
+
agent_name TEXT NOT NULL,
|
|
107
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
108
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
109
|
+
)
|
|
110
|
+
`)
|
|
111
|
+
|
|
112
|
+
targetDb.exec(`
|
|
113
|
+
CREATE TABLE IF NOT EXISTS session_agents (
|
|
114
|
+
session_id TEXT PRIMARY KEY,
|
|
115
|
+
agent_name TEXT NOT NULL,
|
|
116
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
117
|
+
)
|
|
118
|
+
`)
|
|
119
|
+
|
|
99
120
|
dbLogger.log('Model preferences migrations complete')
|
|
100
121
|
}
|
|
101
122
|
|
|
@@ -147,6 +168,50 @@ export function setSessionModel(sessionId: string, modelId: string): void {
|
|
|
147
168
|
).run(sessionId, modelId)
|
|
148
169
|
}
|
|
149
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Get the agent preference for a channel.
|
|
173
|
+
*/
|
|
174
|
+
export function getChannelAgent(channelId: string): string | undefined {
|
|
175
|
+
const db = getDatabase()
|
|
176
|
+
const row = db
|
|
177
|
+
.prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
|
|
178
|
+
.get(channelId) as { agent_name: string } | undefined
|
|
179
|
+
return row?.agent_name
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Set the agent preference for a channel.
|
|
184
|
+
*/
|
|
185
|
+
export function setChannelAgent(channelId: string, agentName: string): void {
|
|
186
|
+
const db = getDatabase()
|
|
187
|
+
db.prepare(
|
|
188
|
+
`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
|
|
189
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
190
|
+
ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`
|
|
191
|
+
).run(channelId, agentName, agentName)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the agent preference for a session.
|
|
196
|
+
*/
|
|
197
|
+
export function getSessionAgent(sessionId: string): string | undefined {
|
|
198
|
+
const db = getDatabase()
|
|
199
|
+
const row = db
|
|
200
|
+
.prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
|
|
201
|
+
.get(sessionId) as { agent_name: string } | undefined
|
|
202
|
+
return row?.agent_name
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Set the agent preference for a session.
|
|
207
|
+
*/
|
|
208
|
+
export function setSessionAgent(sessionId: string, agentName: string): void {
|
|
209
|
+
const db = getDatabase()
|
|
210
|
+
db.prepare(
|
|
211
|
+
`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`
|
|
212
|
+
).run(sessionId, agentName)
|
|
213
|
+
}
|
|
214
|
+
|
|
150
215
|
export function closeDatabase(): void {
|
|
151
216
|
if (db) {
|
|
152
217
|
db.close()
|
package/src/discord-bot.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// Core Discord bot module that handles message events and bot lifecycle.
|
|
2
|
+
// Bridges Discord messages to OpenCode sessions, manages voice connections,
|
|
3
|
+
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
|
+
|
|
1
5
|
import { getDatabase, closeDatabase } from './database.js'
|
|
2
6
|
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
|
|
3
7
|
import {
|
|
@@ -21,9 +25,10 @@ import {
|
|
|
21
25
|
registerVoiceStateHandler,
|
|
22
26
|
} from './voice-handler.js'
|
|
23
27
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from './
|
|
28
|
+
getCompactSessionContext,
|
|
29
|
+
getLastSessionId,
|
|
30
|
+
} from './markdown.js'
|
|
31
|
+
import { handleOpencodeSession } from './session-handler.js'
|
|
27
32
|
import { registerInteractionHandler } from './interaction-handler.js'
|
|
28
33
|
|
|
29
34
|
export { getDatabase, closeDatabase } from './database.js'
|
|
@@ -236,34 +241,39 @@ export async function startDiscordBot({
|
|
|
236
241
|
|
|
237
242
|
let messageContent = message.content || ''
|
|
238
243
|
|
|
239
|
-
let
|
|
240
|
-
|
|
244
|
+
let currentSessionContext: string | undefined
|
|
245
|
+
let lastSessionContext: string | undefined
|
|
246
|
+
|
|
247
|
+
if (projectDirectory) {
|
|
241
248
|
try {
|
|
242
249
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
243
|
-
const
|
|
244
|
-
|
|
250
|
+
const client = getClient()
|
|
251
|
+
|
|
252
|
+
// get current session context (without system prompt, it would be duplicated)
|
|
253
|
+
if (row.session_id) {
|
|
254
|
+
currentSessionContext = await getCompactSessionContext({
|
|
255
|
+
client,
|
|
256
|
+
sessionId: row.session_id,
|
|
257
|
+
includeSystemPrompt: false,
|
|
258
|
+
maxMessages: 15,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// get last session context (with system prompt for project context)
|
|
263
|
+
const lastSessionId = await getLastSessionId({
|
|
264
|
+
client,
|
|
265
|
+
excludeSessionId: row.session_id,
|
|
245
266
|
})
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (m.info.role === 'user') {
|
|
253
|
-
const textParts = (m.parts || []).filter((p) => p.type === 'text')
|
|
254
|
-
return textParts
|
|
255
|
-
.map((p) => ('text' in p ? p.text : ''))
|
|
256
|
-
.filter(Boolean)
|
|
257
|
-
.join('\n')
|
|
258
|
-
}
|
|
259
|
-
const assistantInfo = m.info as { text?: string }
|
|
260
|
-
return assistantInfo.text?.slice(0, 500)
|
|
261
|
-
})()
|
|
262
|
-
return `[${role}]: ${text || '(no text)'}`
|
|
267
|
+
if (lastSessionId) {
|
|
268
|
+
lastSessionContext = await getCompactSessionContext({
|
|
269
|
+
client,
|
|
270
|
+
sessionId: lastSessionId,
|
|
271
|
+
includeSystemPrompt: true,
|
|
272
|
+
maxMessages: 10,
|
|
263
273
|
})
|
|
264
|
-
|
|
274
|
+
}
|
|
265
275
|
} catch (e) {
|
|
266
|
-
voiceLogger.
|
|
276
|
+
voiceLogger.error(`Could not get session context:`, e)
|
|
267
277
|
}
|
|
268
278
|
}
|
|
269
279
|
|
|
@@ -272,25 +282,24 @@ export async function startDiscordBot({
|
|
|
272
282
|
thread,
|
|
273
283
|
projectDirectory,
|
|
274
284
|
appId: currentAppId,
|
|
275
|
-
|
|
285
|
+
currentSessionContext,
|
|
286
|
+
lastSessionContext,
|
|
276
287
|
})
|
|
277
288
|
if (transcription) {
|
|
278
289
|
messageContent = transcription
|
|
279
290
|
}
|
|
280
291
|
|
|
281
|
-
const fileAttachments = getFileAttachments(message)
|
|
292
|
+
const fileAttachments = await getFileAttachments(message)
|
|
282
293
|
const textAttachmentsContent = await getTextAttachments(message)
|
|
283
294
|
const promptWithAttachments = textAttachmentsContent
|
|
284
295
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
285
296
|
: messageContent
|
|
286
|
-
const parsedCommand = parseSlashCommand(messageContent)
|
|
287
297
|
await handleOpencodeSession({
|
|
288
298
|
prompt: promptWithAttachments,
|
|
289
299
|
thread,
|
|
290
300
|
projectDirectory,
|
|
291
301
|
originalMessage: message,
|
|
292
302
|
images: fileAttachments,
|
|
293
|
-
parsedCommand,
|
|
294
303
|
channelId: parent?.id,
|
|
295
304
|
})
|
|
296
305
|
return
|
|
@@ -376,19 +385,17 @@ export async function startDiscordBot({
|
|
|
376
385
|
messageContent = transcription
|
|
377
386
|
}
|
|
378
387
|
|
|
379
|
-
const fileAttachments = getFileAttachments(message)
|
|
388
|
+
const fileAttachments = await getFileAttachments(message)
|
|
380
389
|
const textAttachmentsContent = await getTextAttachments(message)
|
|
381
390
|
const promptWithAttachments = textAttachmentsContent
|
|
382
391
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
383
392
|
: messageContent
|
|
384
|
-
const parsedCommand = parseSlashCommand(messageContent)
|
|
385
393
|
await handleOpencodeSession({
|
|
386
394
|
prompt: promptWithAttachments,
|
|
387
395
|
thread,
|
|
388
396
|
projectDirectory,
|
|
389
397
|
originalMessage: message,
|
|
390
398
|
images: fileAttachments,
|
|
391
|
-
parsedCommand,
|
|
392
399
|
channelId: textChannel.id,
|
|
393
400
|
})
|
|
394
401
|
} else {
|