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,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
|
+
}
|
|
@@ -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,184 @@
|
|
|
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 } 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
|
+
await handleOpencodeSession({
|
|
90
|
+
prompt: fullPrompt,
|
|
91
|
+
thread,
|
|
92
|
+
projectDirectory,
|
|
93
|
+
channelId: textChannel.id,
|
|
94
|
+
})
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.error('[SESSION] Error:', error)
|
|
97
|
+
await command.editReply(
|
|
98
|
+
`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function handleSessionAutocomplete({
|
|
104
|
+
interaction,
|
|
105
|
+
appId,
|
|
106
|
+
}: AutocompleteContext): Promise<void> {
|
|
107
|
+
const focusedOption = interaction.options.getFocused(true)
|
|
108
|
+
|
|
109
|
+
if (focusedOption.name !== 'files') {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const focusedValue = focusedOption.value
|
|
114
|
+
|
|
115
|
+
const parts = focusedValue.split(',')
|
|
116
|
+
const previousFiles = parts
|
|
117
|
+
.slice(0, -1)
|
|
118
|
+
.map((f) => f.trim())
|
|
119
|
+
.filter((f) => f)
|
|
120
|
+
const currentQuery = (parts[parts.length - 1] || '').trim()
|
|
121
|
+
|
|
122
|
+
let projectDirectory: string | undefined
|
|
123
|
+
|
|
124
|
+
if (interaction.channel) {
|
|
125
|
+
const channel = interaction.channel
|
|
126
|
+
if (channel.type === ChannelType.GuildText) {
|
|
127
|
+
const textChannel = channel as TextChannel
|
|
128
|
+
if (textChannel.topic) {
|
|
129
|
+
const extracted = extractTagsArrays({
|
|
130
|
+
xml: textChannel.topic,
|
|
131
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
132
|
+
})
|
|
133
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
134
|
+
if (channelAppId && channelAppId !== appId) {
|
|
135
|
+
await interaction.respond([])
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!projectDirectory) {
|
|
144
|
+
await interaction.respond([])
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
150
|
+
|
|
151
|
+
const response = await getClient().find.files({
|
|
152
|
+
query: {
|
|
153
|
+
query: currentQuery || '',
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const files = response.data || []
|
|
158
|
+
|
|
159
|
+
const prefix =
|
|
160
|
+
previousFiles.length > 0 ? previousFiles.join(', ') + ', ' : ''
|
|
161
|
+
|
|
162
|
+
const choices = files
|
|
163
|
+
.map((file: string) => {
|
|
164
|
+
const fullValue = prefix + file
|
|
165
|
+
const allFiles = [...previousFiles, file]
|
|
166
|
+
const allBasenames = allFiles.map((f) => f.split('/').pop() || f)
|
|
167
|
+
let displayName = allBasenames.join(', ')
|
|
168
|
+
if (displayName.length > 100) {
|
|
169
|
+
displayName = '…' + displayName.slice(-97)
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
name: displayName,
|
|
173
|
+
value: fullValue,
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
.filter((choice) => choice.value.length <= 100)
|
|
177
|
+
.slice(0, 25)
|
|
178
|
+
|
|
179
|
+
await interaction.respond(choices)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
logger.error('[AUTOCOMPLETE] Error fetching files:', error)
|
|
182
|
+
await interaction.respond([])
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -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>
|