kimaki 0.4.24 → 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.
- package/LICENSE +21 -0
- package/bin.js +6 -1
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +3 -0
- package/dist/cli.js +93 -14
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +97 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +294 -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 +144 -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/database.js +3 -0
- package/dist/discord-bot.js +3 -0
- package/dist/discord-utils.js +10 -1
- 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 +71 -697
- package/dist/logger.js +3 -0
- package/dist/markdown.js +3 -0
- package/dist/message-formatting.js +41 -6
- package/dist/opencode.js +3 -0
- package/dist/session-handler.js +47 -3
- package/dist/system-message.js +16 -0
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +3 -0
- package/dist/voice.js +3 -0
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +11 -12
- package/src/ai-tool-to-genai.ts +4 -0
- package/src/channel-management.ts +4 -0
- package/src/cli.ts +93 -14
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +138 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/{fork.ts → commands/fork.ts} +39 -5
- package/src/{model-command.ts → commands/model.ts} +7 -5
- 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 +186 -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/database.ts +4 -0
- package/src/discord-bot.ts +4 -0
- package/src/discord-utils.ts +12 -0
- 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 +81 -919
- package/src/logger.ts +4 -0
- package/src/markdown.ts +4 -0
- package/src/message-formatting.ts +52 -7
- package/src/opencode.ts +4 -0
- package/src/session-handler.ts +70 -3
- package/src/system-message.ts +17 -0
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +4 -0
- package/src/voice.ts +4 -0
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// /add-project command - Create Discord channels for an existing OpenCode project.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import type { CommandContext, AutocompleteContext } from './types.js'
|
|
6
|
+
import { getDatabase } from '../database.js'
|
|
7
|
+
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
8
|
+
import { createProjectChannels } from '../channel-management.js'
|
|
9
|
+
import { createLogger } from '../logger.js'
|
|
10
|
+
|
|
11
|
+
const logger = createLogger('ADD-PROJECT')
|
|
12
|
+
|
|
13
|
+
export async function handleAddProjectCommand({
|
|
14
|
+
command,
|
|
15
|
+
appId,
|
|
16
|
+
}: CommandContext): Promise<void> {
|
|
17
|
+
await command.deferReply({ ephemeral: false })
|
|
18
|
+
|
|
19
|
+
const projectId = command.options.getString('project', true)
|
|
20
|
+
const guild = command.guild
|
|
21
|
+
|
|
22
|
+
if (!guild) {
|
|
23
|
+
await command.editReply('This command can only be used in a guild')
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const currentDir = process.cwd()
|
|
29
|
+
const getClient = await initializeOpencodeForDirectory(currentDir)
|
|
30
|
+
|
|
31
|
+
const projectsResponse = await getClient().project.list({})
|
|
32
|
+
if (!projectsResponse.data) {
|
|
33
|
+
await command.editReply('Failed to fetch projects')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const project = projectsResponse.data.find((p) => p.id === projectId)
|
|
38
|
+
|
|
39
|
+
if (!project) {
|
|
40
|
+
await command.editReply('Project not found')
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const directory = project.worktree
|
|
45
|
+
|
|
46
|
+
if (!fs.existsSync(directory)) {
|
|
47
|
+
await command.editReply(`Directory does not exist: ${directory}`)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const db = getDatabase()
|
|
52
|
+
const existingChannel = db
|
|
53
|
+
.prepare(
|
|
54
|
+
'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
55
|
+
)
|
|
56
|
+
.get(directory, 'text') as { channel_id: string } | undefined
|
|
57
|
+
|
|
58
|
+
if (existingChannel) {
|
|
59
|
+
await command.editReply(
|
|
60
|
+
`A channel already exists for this directory: <#${existingChannel.channel_id}>`,
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { textChannelId, voiceChannelId, channelName } =
|
|
66
|
+
await createProjectChannels({
|
|
67
|
+
guild,
|
|
68
|
+
projectDirectory: directory,
|
|
69
|
+
appId,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await command.editReply(
|
|
73
|
+
`✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
logger.log(`Created channels for project ${channelName} at ${directory}`)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.error('[ADD-PROJECT] Error:', error)
|
|
79
|
+
await command.editReply(
|
|
80
|
+
`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function handleAddProjectAutocomplete({
|
|
86
|
+
interaction,
|
|
87
|
+
appId,
|
|
88
|
+
}: AutocompleteContext): Promise<void> {
|
|
89
|
+
const focusedValue = interaction.options.getFocused()
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const currentDir = process.cwd()
|
|
93
|
+
const getClient = await initializeOpencodeForDirectory(currentDir)
|
|
94
|
+
|
|
95
|
+
const projectsResponse = await getClient().project.list({})
|
|
96
|
+
if (!projectsResponse.data) {
|
|
97
|
+
await interaction.respond([])
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const db = getDatabase()
|
|
102
|
+
const existingDirs = db
|
|
103
|
+
.prepare(
|
|
104
|
+
'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
|
|
105
|
+
)
|
|
106
|
+
.all('text') as { directory: string }[]
|
|
107
|
+
const existingDirSet = new Set(existingDirs.map((row) => row.directory))
|
|
108
|
+
|
|
109
|
+
const availableProjects = projectsResponse.data.filter(
|
|
110
|
+
(project) => !existingDirSet.has(project.worktree),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const projects = availableProjects
|
|
114
|
+
.filter((project) => {
|
|
115
|
+
const baseName = path.basename(project.worktree)
|
|
116
|
+
const searchText = `${baseName} ${project.worktree}`.toLowerCase()
|
|
117
|
+
return searchText.includes(focusedValue.toLowerCase())
|
|
118
|
+
})
|
|
119
|
+
.sort((a, b) => {
|
|
120
|
+
const aTime = a.time.initialized || a.time.created
|
|
121
|
+
const bTime = b.time.initialized || b.time.created
|
|
122
|
+
return bTime - aTime
|
|
123
|
+
})
|
|
124
|
+
.slice(0, 25)
|
|
125
|
+
.map((project) => {
|
|
126
|
+
const name = `${path.basename(project.worktree)} (${project.worktree})`
|
|
127
|
+
return {
|
|
128
|
+
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
129
|
+
value: project.id,
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
await interaction.respond(projects)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger.error('[AUTOCOMPLETE] Error fetching projects:', error)
|
|
136
|
+
await interaction.respond([])
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// /fork command - Fork the session from a past user message.
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
ChatInputCommandInteraction,
|
|
3
5
|
StringSelectMenuInteraction,
|
|
@@ -8,10 +10,11 @@ import {
|
|
|
8
10
|
type ThreadChannel,
|
|
9
11
|
} from 'discord.js'
|
|
10
12
|
import type { TextPart } from '@opencode-ai/sdk'
|
|
11
|
-
import { getDatabase } from '
|
|
12
|
-
import { initializeOpencodeForDirectory } from '
|
|
13
|
-
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '
|
|
14
|
-
import {
|
|
13
|
+
import { getDatabase } from '../database.js'
|
|
14
|
+
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
15
|
+
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
|
|
16
|
+
import { collectLastAssistantParts } from '../message-formatting.js'
|
|
17
|
+
import { createLogger } from '../logger.js'
|
|
15
18
|
|
|
16
19
|
const sessionLogger = createLogger('SESSION')
|
|
17
20
|
const forkLogger = createLogger('FORK')
|
|
@@ -209,7 +212,38 @@ export async function handleForkSelectMenu(interaction: StringSelectMenuInteract
|
|
|
209
212
|
|
|
210
213
|
await sendThreadMessage(
|
|
211
214
|
thread,
|
|
212
|
-
`**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}
|
|
215
|
+
`**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\``,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// Fetch and display the last assistant messages from the forked session
|
|
219
|
+
const messagesResponse = await getClient().session.messages({
|
|
220
|
+
path: { id: forkedSession.id },
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if (messagesResponse.data) {
|
|
224
|
+
const { partIds, content } = collectLastAssistantParts({
|
|
225
|
+
messages: messagesResponse.data,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
if (content.trim()) {
|
|
229
|
+
const discordMessage = await sendThreadMessage(thread, content)
|
|
230
|
+
|
|
231
|
+
// Store part-message mappings for future reference
|
|
232
|
+
const stmt = getDatabase().prepare(
|
|
233
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
234
|
+
)
|
|
235
|
+
const transaction = getDatabase().transaction((ids: string[]) => {
|
|
236
|
+
for (const partId of ids) {
|
|
237
|
+
stmt.run(partId, discordMessage.id, thread.id)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
transaction(partIds)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await sendThreadMessage(
|
|
245
|
+
thread,
|
|
246
|
+
`You can now continue the conversation from this point.`,
|
|
213
247
|
)
|
|
214
248
|
|
|
215
249
|
await interaction.editReply(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// /model command - Set the preferred model for this channel or session.
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
ChatInputCommandInteraction,
|
|
3
5
|
StringSelectMenuInteraction,
|
|
@@ -8,10 +10,10 @@ import {
|
|
|
8
10
|
type TextChannel,
|
|
9
11
|
} from 'discord.js'
|
|
10
12
|
import crypto from 'node:crypto'
|
|
11
|
-
import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '
|
|
12
|
-
import { initializeOpencodeForDirectory } from '
|
|
13
|
-
import { resolveTextChannel, getKimakiMetadata } from '
|
|
14
|
-
import { createLogger } from '
|
|
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 { createLogger } from '../logger.js'
|
|
15
17
|
|
|
16
18
|
const modelLogger = createLogger('MODEL')
|
|
17
19
|
|
|
@@ -25,7 +27,7 @@ const pendingModelContexts = new Map<string, {
|
|
|
25
27
|
providerName?: string
|
|
26
28
|
}>()
|
|
27
29
|
|
|
28
|
-
type ProviderInfo = {
|
|
30
|
+
export type ProviderInfo = {
|
|
29
31
|
id: string
|
|
30
32
|
name: string
|
|
31
33
|
models: Record<
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Queue commands - /queue, /clear-queue
|
|
2
|
+
|
|
3
|
+
import { ChannelType, type ThreadChannel } from 'discord.js'
|
|
4
|
+
import type { CommandContext } from './types.js'
|
|
5
|
+
import { getDatabase } from '../database.js'
|
|
6
|
+
import {
|
|
7
|
+
resolveTextChannel,
|
|
8
|
+
getKimakiMetadata,
|
|
9
|
+
sendThreadMessage,
|
|
10
|
+
SILENT_MESSAGE_FLAGS,
|
|
11
|
+
} from '../discord-utils.js'
|
|
12
|
+
import {
|
|
13
|
+
handleOpencodeSession,
|
|
14
|
+
abortControllers,
|
|
15
|
+
addToQueue,
|
|
16
|
+
getQueueLength,
|
|
17
|
+
clearQueue,
|
|
18
|
+
} from '../session-handler.js'
|
|
19
|
+
import { createLogger } from '../logger.js'
|
|
20
|
+
|
|
21
|
+
const logger = createLogger('QUEUE')
|
|
22
|
+
|
|
23
|
+
export async function handleQueueCommand({
|
|
24
|
+
command,
|
|
25
|
+
}: CommandContext): Promise<void> {
|
|
26
|
+
const message = command.options.getString('message', true)
|
|
27
|
+
const channel = command.channel
|
|
28
|
+
|
|
29
|
+
if (!channel) {
|
|
30
|
+
await command.reply({
|
|
31
|
+
content: 'This command can only be used in a channel',
|
|
32
|
+
ephemeral: true,
|
|
33
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
34
|
+
})
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isThread = [
|
|
39
|
+
ChannelType.PublicThread,
|
|
40
|
+
ChannelType.PrivateThread,
|
|
41
|
+
ChannelType.AnnouncementThread,
|
|
42
|
+
].includes(channel.type)
|
|
43
|
+
|
|
44
|
+
if (!isThread) {
|
|
45
|
+
await command.reply({
|
|
46
|
+
content: 'This command can only be used in a thread with an active session',
|
|
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. Send a message directly instead.',
|
|
60
|
+
ephemeral: true,
|
|
61
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
62
|
+
})
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check if there's an active request running
|
|
67
|
+
const hasActiveRequest = abortControllers.has(row.session_id)
|
|
68
|
+
|
|
69
|
+
if (!hasActiveRequest) {
|
|
70
|
+
// No active request, send immediately
|
|
71
|
+
const textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
72
|
+
const { projectDirectory } = getKimakiMetadata(textChannel)
|
|
73
|
+
|
|
74
|
+
if (!projectDirectory) {
|
|
75
|
+
await command.reply({
|
|
76
|
+
content: 'Could not determine project directory',
|
|
77
|
+
ephemeral: true,
|
|
78
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
79
|
+
})
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await command.reply({
|
|
84
|
+
content: `» **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
|
|
85
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
logger.log(
|
|
89
|
+
`[QUEUE] No active request, sending immediately in thread ${channel.id}`,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
handleOpencodeSession({
|
|
93
|
+
prompt: message,
|
|
94
|
+
thread: channel as ThreadChannel,
|
|
95
|
+
projectDirectory,
|
|
96
|
+
channelId: textChannel?.id || channel.id,
|
|
97
|
+
}).catch(async (e) => {
|
|
98
|
+
logger.error(`[QUEUE] Failed to send message:`, e)
|
|
99
|
+
const errorMsg = e instanceof Error ? e.message : String(e)
|
|
100
|
+
await sendThreadMessage(
|
|
101
|
+
channel as ThreadChannel,
|
|
102
|
+
`✗ Failed: ${errorMsg.slice(0, 200)}`,
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Add to queue
|
|
110
|
+
const queuePosition = addToQueue({
|
|
111
|
+
threadId: channel.id,
|
|
112
|
+
message: {
|
|
113
|
+
prompt: message,
|
|
114
|
+
userId: command.user.id,
|
|
115
|
+
username: command.user.displayName,
|
|
116
|
+
queuedAt: Date.now(),
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await command.reply({
|
|
121
|
+
content: `✅ Message queued (position: ${queuePosition}). Will be sent after current response.`,
|
|
122
|
+
ephemeral: true,
|
|
123
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
logger.log(
|
|
127
|
+
`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function handleClearQueueCommand({
|
|
132
|
+
command,
|
|
133
|
+
}: CommandContext): Promise<void> {
|
|
134
|
+
const channel = command.channel
|
|
135
|
+
|
|
136
|
+
if (!channel) {
|
|
137
|
+
await command.reply({
|
|
138
|
+
content: 'This command can only be used in a channel',
|
|
139
|
+
ephemeral: true,
|
|
140
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
141
|
+
})
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isThread = [
|
|
146
|
+
ChannelType.PublicThread,
|
|
147
|
+
ChannelType.PrivateThread,
|
|
148
|
+
ChannelType.AnnouncementThread,
|
|
149
|
+
].includes(channel.type)
|
|
150
|
+
|
|
151
|
+
if (!isThread) {
|
|
152
|
+
await command.reply({
|
|
153
|
+
content: 'This command can only be used in a thread',
|
|
154
|
+
ephemeral: true,
|
|
155
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
156
|
+
})
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const queueLength = getQueueLength(channel.id)
|
|
161
|
+
|
|
162
|
+
if (queueLength === 0) {
|
|
163
|
+
await command.reply({
|
|
164
|
+
content: 'No messages in queue',
|
|
165
|
+
ephemeral: true,
|
|
166
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
167
|
+
})
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
clearQueue(channel.id)
|
|
172
|
+
|
|
173
|
+
await command.reply({
|
|
174
|
+
content: `🗑 Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
|
|
175
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
logger.log(
|
|
179
|
+
`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`,
|
|
180
|
+
)
|
|
181
|
+
}
|