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.
- package/bin.js +70 -0
- package/dist/ai-tool-to-genai.js +210 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +97 -0
- package/dist/cli.js +709 -0
- 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 +184 -0
- package/dist/discord-bot.js +384 -0
- package/dist/discord-utils.js +217 -0
- package/dist/escape-backticks.test.js +410 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +297 -0
- package/dist/genai.js +232 -0
- package/dist/interaction-handler.js +144 -0
- package/dist/logger.js +51 -0
- package/dist/markdown.js +310 -0
- package/dist/markdown.test.js +262 -0
- package/dist/message-formatting.js +273 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode.js +216 -0
- package/dist/session-handler.js +580 -0
- package/dist/system-message.js +61 -0
- package/dist/tools.js +356 -0
- package/dist/utils.js +85 -0
- package/dist/voice-handler.js +541 -0
- package/dist/voice.js +314 -0
- package/dist/worker-types.js +4 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +60 -0
- 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.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +255 -0
- package/src/channel-management.ts +161 -0
- package/src/cli.ts +1010 -0
- 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/commands/fork.ts +257 -0
- package/src/commands/model.ts +402 -0
- 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 +220 -0
- package/src/discord-bot.ts +513 -0
- package/src/discord-utils.ts +282 -0
- package/src/escape-backticks.test.ts +447 -0
- package/src/format-tables.test.ts +440 -0
- package/src/format-tables.ts +110 -0
- package/src/genai-worker-wrapper.ts +160 -0
- package/src/genai-worker.ts +366 -0
- package/src/genai.ts +321 -0
- package/src/interaction-handler.ts +187 -0
- package/src/logger.ts +57 -0
- package/src/markdown.test.ts +358 -0
- package/src/markdown.ts +365 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +340 -0
- package/src/openai-realtime.ts +363 -0
- package/src/opencode.ts +277 -0
- package/src/session-handler.ts +758 -0
- package/src/system-message.ts +62 -0
- package/src/tools.ts +428 -0
- package/src/utils.ts +118 -0
- package/src/voice-handler.ts +760 -0
- package/src/voice.ts +432 -0
- package/src/worker-types.ts +66 -0
- package/src/xml.test.ts +37 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// /abort command - Abort the current OpenCode request in this thread.
|
|
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 { abortControllers } from '../session-handler.js'
|
|
9
|
+
import { createLogger } from '../logger.js'
|
|
10
|
+
|
|
11
|
+
const logger = createLogger('ABORT')
|
|
12
|
+
|
|
13
|
+
export async function handleAbortCommand({
|
|
14
|
+
command,
|
|
15
|
+
}: CommandContext): Promise<void> {
|
|
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 textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
43
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
44
|
+
|
|
45
|
+
if (!directory) {
|
|
46
|
+
await command.reply({
|
|
47
|
+
content: 'Could not determine project directory for this channel',
|
|
48
|
+
ephemeral: true,
|
|
49
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
50
|
+
})
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const row = getDatabase()
|
|
55
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
56
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
57
|
+
|
|
58
|
+
if (!row?.session_id) {
|
|
59
|
+
await command.reply({
|
|
60
|
+
content: 'No active session in this thread',
|
|
61
|
+
ephemeral: true,
|
|
62
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
63
|
+
})
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sessionId = row.session_id
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const existingController = abortControllers.get(sessionId)
|
|
71
|
+
if (existingController) {
|
|
72
|
+
existingController.abort(new Error('User requested abort'))
|
|
73
|
+
abortControllers.delete(sessionId)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
77
|
+
await getClient().session.abort({
|
|
78
|
+
path: { id: sessionId },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await command.reply({
|
|
82
|
+
content: `🛑 Request **aborted**`,
|
|
83
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
84
|
+
})
|
|
85
|
+
logger.log(`Session ${sessionId} aborted by user`)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
logger.error('[ABORT] Error:', error)
|
|
88
|
+
await command.reply({
|
|
89
|
+
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
90
|
+
ephemeral: true,
|
|
91
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
botName: command.client.user?.username,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await command.editReply(
|
|
74
|
+
`✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
logger.log(`Created channels for project ${channelName} at ${directory}`)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logger.error('[ADD-PROJECT] Error:', error)
|
|
80
|
+
await command.editReply(
|
|
81
|
+
`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function handleAddProjectAutocomplete({
|
|
87
|
+
interaction,
|
|
88
|
+
appId,
|
|
89
|
+
}: AutocompleteContext): Promise<void> {
|
|
90
|
+
const focusedValue = interaction.options.getFocused()
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const currentDir = process.cwd()
|
|
94
|
+
const getClient = await initializeOpencodeForDirectory(currentDir)
|
|
95
|
+
|
|
96
|
+
const projectsResponse = await getClient().project.list({})
|
|
97
|
+
if (!projectsResponse.data) {
|
|
98
|
+
await interaction.respond([])
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const db = getDatabase()
|
|
103
|
+
const existingDirs = db
|
|
104
|
+
.prepare(
|
|
105
|
+
'SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?',
|
|
106
|
+
)
|
|
107
|
+
.all('text') as { directory: string }[]
|
|
108
|
+
const existingDirSet = new Set(existingDirs.map((row) => row.directory))
|
|
109
|
+
|
|
110
|
+
const availableProjects = projectsResponse.data.filter(
|
|
111
|
+
(project) => !existingDirSet.has(project.worktree),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const projects = availableProjects
|
|
115
|
+
.filter((project) => {
|
|
116
|
+
const baseName = path.basename(project.worktree)
|
|
117
|
+
const searchText = `${baseName} ${project.worktree}`.toLowerCase()
|
|
118
|
+
return searchText.includes(focusedValue.toLowerCase())
|
|
119
|
+
})
|
|
120
|
+
.sort((a, b) => {
|
|
121
|
+
const aTime = a.time.initialized || a.time.created
|
|
122
|
+
const bTime = b.time.initialized || b.time.created
|
|
123
|
+
return bTime - aTime
|
|
124
|
+
})
|
|
125
|
+
.slice(0, 25)
|
|
126
|
+
.map((project) => {
|
|
127
|
+
const name = `${path.basename(project.worktree)} (${project.worktree})`
|
|
128
|
+
return {
|
|
129
|
+
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
130
|
+
value: project.id,
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await interaction.respond(projects)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
logger.error('[AUTOCOMPLETE] Error fetching projects:', error)
|
|
137
|
+
await interaction.respond([])
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// /agent command - Set the preferred agent for this channel or session.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ChatInputCommandInteraction,
|
|
5
|
+
StringSelectMenuInteraction,
|
|
6
|
+
StringSelectMenuBuilder,
|
|
7
|
+
ActionRowBuilder,
|
|
8
|
+
ChannelType,
|
|
9
|
+
type ThreadChannel,
|
|
10
|
+
type TextChannel,
|
|
11
|
+
} from 'discord.js'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
|
|
14
|
+
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
15
|
+
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
|
|
16
|
+
import { createLogger } from '../logger.js'
|
|
17
|
+
|
|
18
|
+
const agentLogger = createLogger('AGENT')
|
|
19
|
+
|
|
20
|
+
const pendingAgentContexts = new Map<string, {
|
|
21
|
+
dir: string
|
|
22
|
+
channelId: string
|
|
23
|
+
sessionId?: string
|
|
24
|
+
isThread: boolean
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
export async function handleAgentCommand({
|
|
28
|
+
interaction,
|
|
29
|
+
appId,
|
|
30
|
+
}: {
|
|
31
|
+
interaction: ChatInputCommandInteraction
|
|
32
|
+
appId: string
|
|
33
|
+
}): Promise<void> {
|
|
34
|
+
await interaction.deferReply({ ephemeral: true })
|
|
35
|
+
|
|
36
|
+
runModelMigrations()
|
|
37
|
+
|
|
38
|
+
const channel = interaction.channel
|
|
39
|
+
|
|
40
|
+
if (!channel) {
|
|
41
|
+
await interaction.editReply({ content: 'This command can only be used in a channel' })
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isThread = [
|
|
46
|
+
ChannelType.PublicThread,
|
|
47
|
+
ChannelType.PrivateThread,
|
|
48
|
+
ChannelType.AnnouncementThread,
|
|
49
|
+
].includes(channel.type)
|
|
50
|
+
|
|
51
|
+
let projectDirectory: string | undefined
|
|
52
|
+
let channelAppId: string | undefined
|
|
53
|
+
let targetChannelId: string
|
|
54
|
+
let sessionId: string | undefined
|
|
55
|
+
|
|
56
|
+
if (isThread) {
|
|
57
|
+
const thread = channel as ThreadChannel
|
|
58
|
+
const textChannel = await resolveTextChannel(thread)
|
|
59
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
60
|
+
projectDirectory = metadata.projectDirectory
|
|
61
|
+
channelAppId = metadata.channelAppId
|
|
62
|
+
targetChannelId = textChannel?.id || channel.id
|
|
63
|
+
|
|
64
|
+
const row = getDatabase()
|
|
65
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
66
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
67
|
+
sessionId = row?.session_id
|
|
68
|
+
} else if (channel.type === ChannelType.GuildText) {
|
|
69
|
+
const textChannel = channel as TextChannel
|
|
70
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
71
|
+
projectDirectory = metadata.projectDirectory
|
|
72
|
+
channelAppId = metadata.channelAppId
|
|
73
|
+
targetChannelId = channel.id
|
|
74
|
+
} else {
|
|
75
|
+
await interaction.editReply({ content: 'This command can only be used in text channels or threads' })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (channelAppId && channelAppId !== appId) {
|
|
80
|
+
await interaction.editReply({ content: 'This channel is not configured for this bot' })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!projectDirectory) {
|
|
85
|
+
await interaction.editReply({ content: 'This channel is not configured with a project directory' })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
91
|
+
|
|
92
|
+
const agentsResponse = await getClient().app.agents({
|
|
93
|
+
query: { directory: projectDirectory },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
97
|
+
await interaction.editReply({ content: 'No agents available' })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const agents = agentsResponse.data
|
|
102
|
+
.filter((a) => a.mode === 'primary' || a.mode === 'all')
|
|
103
|
+
.slice(0, 25)
|
|
104
|
+
|
|
105
|
+
if (agents.length === 0) {
|
|
106
|
+
await interaction.editReply({ content: 'No primary agents available' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
111
|
+
pendingAgentContexts.set(contextHash, {
|
|
112
|
+
dir: projectDirectory,
|
|
113
|
+
channelId: targetChannelId,
|
|
114
|
+
sessionId,
|
|
115
|
+
isThread,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const options = agents.map((agent) => ({
|
|
119
|
+
label: agent.name.slice(0, 100),
|
|
120
|
+
value: agent.name,
|
|
121
|
+
description: (agent.description || `${agent.mode} agent`).slice(0, 100),
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
125
|
+
.setCustomId(`agent_select:${contextHash}`)
|
|
126
|
+
.setPlaceholder('Select an agent')
|
|
127
|
+
.addOptions(options)
|
|
128
|
+
|
|
129
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
130
|
+
|
|
131
|
+
await interaction.editReply({
|
|
132
|
+
content: '**Set Agent Preference**\nSelect an agent:',
|
|
133
|
+
components: [actionRow],
|
|
134
|
+
})
|
|
135
|
+
} catch (error) {
|
|
136
|
+
agentLogger.error('Error loading agents:', error)
|
|
137
|
+
await interaction.editReply({
|
|
138
|
+
content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function handleAgentSelectMenu(
|
|
144
|
+
interaction: StringSelectMenuInteraction
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const customId = interaction.customId
|
|
147
|
+
|
|
148
|
+
if (!customId.startsWith('agent_select:')) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await interaction.deferUpdate()
|
|
153
|
+
|
|
154
|
+
const contextHash = customId.replace('agent_select:', '')
|
|
155
|
+
const context = pendingAgentContexts.get(contextHash)
|
|
156
|
+
|
|
157
|
+
if (!context) {
|
|
158
|
+
await interaction.editReply({
|
|
159
|
+
content: 'Selection expired. Please run /agent again.',
|
|
160
|
+
components: [],
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const selectedAgent = interaction.values[0]
|
|
166
|
+
if (!selectedAgent) {
|
|
167
|
+
await interaction.editReply({
|
|
168
|
+
content: 'No agent selected',
|
|
169
|
+
components: [],
|
|
170
|
+
})
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (context.isThread && context.sessionId) {
|
|
176
|
+
setSessionAgent(context.sessionId, selectedAgent)
|
|
177
|
+
agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
|
|
178
|
+
|
|
179
|
+
await interaction.editReply({
|
|
180
|
+
content: `Agent preference set for this session: **${selectedAgent}**`,
|
|
181
|
+
components: [],
|
|
182
|
+
})
|
|
183
|
+
} else {
|
|
184
|
+
setChannelAgent(context.channelId, selectedAgent)
|
|
185
|
+
agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
|
|
186
|
+
|
|
187
|
+
await interaction.editReply({
|
|
188
|
+
content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
|
|
189
|
+
components: [],
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pendingAgentContexts.delete(contextHash)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
agentLogger.error('Error saving agent preference:', error)
|
|
196
|
+
await interaction.editReply({
|
|
197
|
+
content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
198
|
+
components: [],
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// AskUserQuestion tool handler - Shows Discord dropdowns for AI questions.
|
|
2
|
+
// When the AI uses the AskUserQuestion tool, this module renders dropdowns
|
|
3
|
+
// for each question and collects user responses.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
StringSelectMenuBuilder,
|
|
7
|
+
StringSelectMenuInteraction,
|
|
8
|
+
ActionRowBuilder,
|
|
9
|
+
type ThreadChannel,
|
|
10
|
+
} from 'discord.js'
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { sendThreadMessage } from '../discord-utils.js'
|
|
13
|
+
import { getOpencodeServerPort } from '../opencode.js'
|
|
14
|
+
import { createLogger } from '../logger.js'
|
|
15
|
+
|
|
16
|
+
const logger = createLogger('ASK_QUESTION')
|
|
17
|
+
|
|
18
|
+
// Schema matching the question tool input
|
|
19
|
+
export type AskUserQuestionInput = {
|
|
20
|
+
questions: Array<{
|
|
21
|
+
question: string
|
|
22
|
+
header: string // max 12 chars
|
|
23
|
+
options: Array<{
|
|
24
|
+
label: string
|
|
25
|
+
description: string
|
|
26
|
+
}>
|
|
27
|
+
multiple?: boolean // optional, defaults to false
|
|
28
|
+
}>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type PendingQuestionContext = {
|
|
32
|
+
sessionId: string
|
|
33
|
+
directory: string
|
|
34
|
+
thread: ThreadChannel
|
|
35
|
+
requestId: string // OpenCode question request ID for replying
|
|
36
|
+
questions: AskUserQuestionInput['questions']
|
|
37
|
+
answers: Record<number, string[]> // questionIndex -> selected labels
|
|
38
|
+
totalQuestions: number
|
|
39
|
+
answeredCount: number
|
|
40
|
+
contextHash: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Store pending question contexts by hash
|
|
44
|
+
export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Show dropdown menus for question tool input.
|
|
48
|
+
* Sends one message per question with the dropdown directly under the question text.
|
|
49
|
+
*/
|
|
50
|
+
export async function showAskUserQuestionDropdowns({
|
|
51
|
+
thread,
|
|
52
|
+
sessionId,
|
|
53
|
+
directory,
|
|
54
|
+
requestId,
|
|
55
|
+
input,
|
|
56
|
+
}: {
|
|
57
|
+
thread: ThreadChannel
|
|
58
|
+
sessionId: string
|
|
59
|
+
directory: string
|
|
60
|
+
requestId: string // OpenCode question request ID
|
|
61
|
+
input: AskUserQuestionInput
|
|
62
|
+
}): Promise<void> {
|
|
63
|
+
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
64
|
+
|
|
65
|
+
const context: PendingQuestionContext = {
|
|
66
|
+
sessionId,
|
|
67
|
+
directory,
|
|
68
|
+
thread,
|
|
69
|
+
requestId,
|
|
70
|
+
questions: input.questions,
|
|
71
|
+
answers: {},
|
|
72
|
+
totalQuestions: input.questions.length,
|
|
73
|
+
answeredCount: 0,
|
|
74
|
+
contextHash,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pendingQuestionContexts.set(contextHash, context)
|
|
78
|
+
|
|
79
|
+
// Send one message per question with its dropdown directly underneath
|
|
80
|
+
for (let i = 0; i < input.questions.length; i++) {
|
|
81
|
+
const q = input.questions[i]!
|
|
82
|
+
|
|
83
|
+
// Map options to Discord select menu options
|
|
84
|
+
// Discord max: 25 options per select menu
|
|
85
|
+
const options = [
|
|
86
|
+
...q.options.slice(0, 24).map((opt, optIdx) => ({
|
|
87
|
+
label: opt.label.slice(0, 100),
|
|
88
|
+
value: `${optIdx}`,
|
|
89
|
+
description: opt.description.slice(0, 100),
|
|
90
|
+
})),
|
|
91
|
+
{
|
|
92
|
+
label: 'Other',
|
|
93
|
+
value: 'other',
|
|
94
|
+
description: 'Provide a custom answer in chat',
|
|
95
|
+
},
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
99
|
+
.setCustomId(`ask_question:${contextHash}:${i}`)
|
|
100
|
+
.setPlaceholder(`Select an option`)
|
|
101
|
+
.addOptions(options)
|
|
102
|
+
|
|
103
|
+
// Enable multi-select if the question supports it
|
|
104
|
+
if (q.multiple) {
|
|
105
|
+
selectMenu.setMinValues(1)
|
|
106
|
+
selectMenu.setMaxValues(options.length)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
110
|
+
|
|
111
|
+
await thread.send({
|
|
112
|
+
content: `**${q.header}**\n${q.question}`,
|
|
113
|
+
components: [actionRow],
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle dropdown selection for AskUserQuestion.
|
|
122
|
+
*/
|
|
123
|
+
export async function handleAskQuestionSelectMenu(
|
|
124
|
+
interaction: StringSelectMenuInteraction
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const customId = interaction.customId
|
|
127
|
+
|
|
128
|
+
if (!customId.startsWith('ask_question:')) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const parts = customId.split(':')
|
|
133
|
+
const contextHash = parts[1]
|
|
134
|
+
const questionIndex = parseInt(parts[2]!, 10)
|
|
135
|
+
|
|
136
|
+
if (!contextHash) {
|
|
137
|
+
await interaction.reply({
|
|
138
|
+
content: 'Invalid selection.',
|
|
139
|
+
ephemeral: true,
|
|
140
|
+
})
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const context = pendingQuestionContexts.get(contextHash)
|
|
145
|
+
|
|
146
|
+
if (!context) {
|
|
147
|
+
await interaction.reply({
|
|
148
|
+
content: 'This question has expired. Please ask the AI again.',
|
|
149
|
+
ephemeral: true,
|
|
150
|
+
})
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await interaction.deferUpdate()
|
|
155
|
+
|
|
156
|
+
const selectedValues = interaction.values
|
|
157
|
+
const question = context.questions[questionIndex]
|
|
158
|
+
|
|
159
|
+
if (!question) {
|
|
160
|
+
logger.error(`Question index ${questionIndex} not found in context`)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if "other" was selected
|
|
165
|
+
if (selectedValues.includes('other')) {
|
|
166
|
+
// User wants to provide custom answer
|
|
167
|
+
// For now, mark as "Other" - they can type in chat
|
|
168
|
+
context.answers[questionIndex] = ['Other (please type your answer in chat)']
|
|
169
|
+
} else {
|
|
170
|
+
// Map value indices back to option labels
|
|
171
|
+
context.answers[questionIndex] = selectedValues.map((v) => {
|
|
172
|
+
const optIdx = parseInt(v, 10)
|
|
173
|
+
return question.options[optIdx]?.label || `Option ${optIdx + 1}`
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
context.answeredCount++
|
|
178
|
+
|
|
179
|
+
// Update this question's message: show answer and remove dropdown
|
|
180
|
+
const answeredText = context.answers[questionIndex]!.join(', ')
|
|
181
|
+
await interaction.editReply({
|
|
182
|
+
content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
|
|
183
|
+
components: [], // Remove the dropdown
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Check if all questions are answered
|
|
187
|
+
if (context.answeredCount >= context.totalQuestions) {
|
|
188
|
+
// All questions answered - send result back to session
|
|
189
|
+
await submitQuestionAnswers(context)
|
|
190
|
+
pendingQuestionContexts.delete(contextHash)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Submit all collected answers back to the OpenCode session.
|
|
196
|
+
* Uses the question.reply API to provide answers to the waiting tool.
|
|
197
|
+
*/
|
|
198
|
+
async function submitQuestionAnswers(
|
|
199
|
+
context: PendingQuestionContext
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
try {
|
|
202
|
+
// Build answers array: each element is an array of selected labels for that question
|
|
203
|
+
const answersPayload = context.questions.map((_, i) => {
|
|
204
|
+
return context.answers[i] || []
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Reply to the question using direct HTTP call to OpenCode API
|
|
208
|
+
// (v1 SDK doesn't have question.reply, so we call it directly)
|
|
209
|
+
const port = getOpencodeServerPort(context.directory)
|
|
210
|
+
if (!port) {
|
|
211
|
+
throw new Error('OpenCode server not found for directory')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const response = await fetch(
|
|
215
|
+
`http://127.0.0.1:${port}/question/${context.requestId}/reply`,
|
|
216
|
+
{
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'Content-Type': 'application/json' },
|
|
219
|
+
body: JSON.stringify({ answers: answersPayload }),
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const text = await response.text()
|
|
225
|
+
throw new Error(`Failed to reply to question: ${response.status} ${text}`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
logger.error('Failed to submit answers:', error)
|
|
231
|
+
await sendThreadMessage(
|
|
232
|
+
context.thread,
|
|
233
|
+
`✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check if a tool part is an AskUserQuestion tool.
|
|
240
|
+
* Returns the parsed input if valid, null otherwise.
|
|
241
|
+
*/
|
|
242
|
+
export function parseAskUserQuestionTool(part: {
|
|
243
|
+
type: string
|
|
244
|
+
tool?: string
|
|
245
|
+
state?: { input?: unknown }
|
|
246
|
+
}): AskUserQuestionInput | null {
|
|
247
|
+
if (part.type !== 'tool') {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Check for the tool name (case-insensitive)
|
|
252
|
+
const toolName = part.tool?.toLowerCase()
|
|
253
|
+
if (toolName !== 'question') {
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const input = part.state?.input as AskUserQuestionInput | undefined
|
|
258
|
+
|
|
259
|
+
if (!input?.questions || !Array.isArray(input.questions) || input.questions.length === 0) {
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Validate structure
|
|
264
|
+
for (const q of input.questions) {
|
|
265
|
+
if (
|
|
266
|
+
typeof q.question !== 'string' ||
|
|
267
|
+
typeof q.header !== 'string' ||
|
|
268
|
+
!Array.isArray(q.options) ||
|
|
269
|
+
q.options.length < 2
|
|
270
|
+
) {
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return input
|
|
276
|
+
}
|