kimaki 0.4.38 → 0.4.40
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/dist/cli.js +27 -23
- package/dist/commands/abort.js +15 -6
- package/dist/commands/add-project.js +9 -0
- package/dist/commands/agent.js +13 -1
- package/dist/commands/fork.js +13 -2
- package/dist/commands/model.js +12 -0
- package/dist/commands/remove-project.js +26 -16
- package/dist/commands/resume.js +9 -0
- package/dist/commands/session.js +14 -1
- package/dist/commands/share.js +10 -1
- package/dist/commands/undo-redo.js +13 -4
- package/dist/commands/worktree.js +180 -0
- package/dist/database.js +57 -5
- package/dist/discord-bot.js +48 -10
- package/dist/discord-utils.js +36 -0
- package/dist/errors.js +109 -0
- package/dist/genai-worker.js +18 -16
- package/dist/interaction-handler.js +6 -2
- package/dist/markdown.js +100 -85
- package/dist/markdown.test.js +10 -3
- package/dist/message-formatting.js +50 -37
- package/dist/opencode.js +43 -46
- package/dist/session-handler.js +100 -2
- package/dist/system-message.js +2 -0
- package/dist/tools.js +18 -8
- package/dist/voice-handler.js +48 -25
- package/dist/voice.js +159 -131
- package/package.json +4 -2
- package/src/cli.ts +31 -32
- package/src/commands/abort.ts +17 -7
- package/src/commands/add-project.ts +9 -0
- package/src/commands/agent.ts +13 -1
- package/src/commands/fork.ts +18 -7
- package/src/commands/model.ts +12 -0
- package/src/commands/remove-project.ts +28 -16
- package/src/commands/resume.ts +9 -0
- package/src/commands/session.ts +14 -1
- package/src/commands/share.ts +11 -1
- package/src/commands/undo-redo.ts +15 -6
- package/src/commands/worktree.ts +243 -0
- package/src/database.ts +104 -4
- package/src/discord-bot.ts +49 -9
- package/src/discord-utils.ts +50 -0
- package/src/errors.ts +138 -0
- package/src/genai-worker.ts +20 -17
- package/src/interaction-handler.ts +7 -2
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +112 -95
- package/src/message-formatting.ts +55 -38
- package/src/opencode.ts +52 -49
- package/src/session-handler.ts +118 -3
- package/src/system-message.ts +2 -0
- package/src/tools.ts +18 -8
- package/src/voice-handler.ts +48 -23
- package/src/voice.ts +195 -148
package/src/commands/fork.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
|
14
14
|
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
|
|
15
15
|
import { collectLastAssistantParts } from '../message-formatting.js'
|
|
16
16
|
import { createLogger } from '../logger.js'
|
|
17
|
+
import * as errore from 'errore'
|
|
17
18
|
|
|
18
19
|
const sessionLogger = createLogger('SESSION')
|
|
19
20
|
const forkLogger = createLogger('FORK')
|
|
@@ -71,9 +72,15 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
|
|
|
71
72
|
|
|
72
73
|
const sessionId = row.session_id
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
76
|
+
if (getClient instanceof Error) {
|
|
77
|
+
await interaction.editReply({
|
|
78
|
+
content: `Failed to load messages: ${getClient.message}`,
|
|
79
|
+
})
|
|
80
|
+
return
|
|
81
|
+
}
|
|
76
82
|
|
|
83
|
+
try {
|
|
77
84
|
const messagesResponse = await getClient().session.messages({
|
|
78
85
|
path: { id: sessionId },
|
|
79
86
|
})
|
|
@@ -85,7 +92,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
|
|
|
85
92
|
return
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user')
|
|
95
|
+
const userMessages = messagesResponse.data.filter((m: { info: { role: string } }) => m.info.role === 'user')
|
|
89
96
|
|
|
90
97
|
if (userMessages.length === 0) {
|
|
91
98
|
await interaction.editReply({
|
|
@@ -96,8 +103,8 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
|
|
|
96
103
|
|
|
97
104
|
const recentMessages = userMessages.slice(-25)
|
|
98
105
|
|
|
99
|
-
const options = recentMessages.map((m, index) => {
|
|
100
|
-
const textPart = m.parts.find((p) => p.type === 'text') as
|
|
106
|
+
const options = recentMessages.map((m: { parts: Array<{ type: string; text?: string }>; info: { id: string; time: { created: number } } }, index: number) => {
|
|
107
|
+
const textPart = m.parts.find((p: { type: string }) => p.type === 'text') as
|
|
101
108
|
| { type: 'text'; text: string }
|
|
102
109
|
| undefined
|
|
103
110
|
const preview = textPart?.text?.slice(0, 80) || '(no text)'
|
|
@@ -163,9 +170,13 @@ export async function handleForkSelectMenu(
|
|
|
163
170
|
|
|
164
171
|
await interaction.deferReply({ ephemeral: false })
|
|
165
172
|
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
174
|
+
if (getClient instanceof Error) {
|
|
175
|
+
await interaction.editReply(`Failed to fork session: ${getClient.message}`)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
168
178
|
|
|
179
|
+
try {
|
|
169
180
|
const forkResponse = await getClient().session.fork({
|
|
170
181
|
path: { id: sessionId },
|
|
171
182
|
body: { messageID: selectedMessageId },
|
package/src/commands/model.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
|
15
15
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
|
|
16
16
|
import { abortAndRetrySession } from '../session-handler.js'
|
|
17
17
|
import { createLogger } from '../logger.js'
|
|
18
|
+
import * as errore from 'errore'
|
|
18
19
|
|
|
19
20
|
const modelLogger = createLogger('MODEL')
|
|
20
21
|
|
|
@@ -128,6 +129,10 @@ export async function handleModelCommand({
|
|
|
128
129
|
|
|
129
130
|
try {
|
|
130
131
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
132
|
+
if (getClient instanceof Error) {
|
|
133
|
+
await interaction.editReply({ content: getClient.message })
|
|
134
|
+
return
|
|
135
|
+
}
|
|
131
136
|
|
|
132
137
|
const providersResponse = await getClient().provider.list({
|
|
133
138
|
query: { directory: projectDirectory },
|
|
@@ -232,6 +237,13 @@ export async function handleProviderSelectMenu(
|
|
|
232
237
|
|
|
233
238
|
try {
|
|
234
239
|
const getClient = await initializeOpencodeForDirectory(context.dir)
|
|
240
|
+
if (getClient instanceof Error) {
|
|
241
|
+
await interaction.editReply({
|
|
242
|
+
content: getClient.message,
|
|
243
|
+
components: [],
|
|
244
|
+
})
|
|
245
|
+
return
|
|
246
|
+
}
|
|
235
247
|
|
|
236
248
|
const providersResponse = await getClient().provider.list({
|
|
237
249
|
query: { directory: context.dir },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// /remove-project command - Remove Discord channels for a project.
|
|
2
2
|
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import * as errore from 'errore'
|
|
4
5
|
import type { CommandContext, AutocompleteContext } from './types.js'
|
|
5
6
|
import { getDatabase } from '../database.js'
|
|
6
7
|
import { createLogger } from '../logger.js'
|
|
@@ -36,19 +37,27 @@ export async function handleRemoveProjectCommand({ command, appId }: CommandCont
|
|
|
36
37
|
const failedChannels: string[] = []
|
|
37
38
|
|
|
38
39
|
for (const { channel_id, channel_type } of channels) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const channel = await errore.tryAsync({
|
|
41
|
+
try: () => guild.channels.fetch(channel_id),
|
|
42
|
+
catch: (e) => e as Error,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if (channel instanceof Error) {
|
|
46
|
+
logger.error(`Failed to fetch channel ${channel_id}:`, channel)
|
|
47
|
+
failedChannels.push(`${channel_type}: ${channel_id}`)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (channel) {
|
|
52
|
+
try {
|
|
43
53
|
await channel.delete(`Removed by /remove-project command`)
|
|
44
54
|
deletedChannels.push(`${channel_type}: ${channel_id}`)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.error(`Failed to delete channel ${channel_id}:`, error)
|
|
57
|
+
failedChannels.push(`${channel_type}: ${channel_id}`)
|
|
48
58
|
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
failedChannels.push(`${channel_type}: ${channel_id}`)
|
|
59
|
+
} else {
|
|
60
|
+
deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -103,13 +112,16 @@ export async function handleRemoveProjectAutocomplete({
|
|
|
103
112
|
const projectsInGuild: { directory: string; channelId: string }[] = []
|
|
104
113
|
|
|
105
114
|
for (const { directory, channel_id } of allChannels) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
} catch {
|
|
115
|
+
const channel = await errore.tryAsync({
|
|
116
|
+
try: () => guild.channels.fetch(channel_id),
|
|
117
|
+
catch: (e) => e as Error,
|
|
118
|
+
})
|
|
119
|
+
if (channel instanceof Error) {
|
|
112
120
|
// Channel not in this guild, skip
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (channel) {
|
|
124
|
+
projectsInGuild.push({ directory, channelId: channel_id })
|
|
113
125
|
}
|
|
114
126
|
}
|
|
115
127
|
|
package/src/commands/resume.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../dis
|
|
|
14
14
|
import { extractTagsArrays } from '../xml.js'
|
|
15
15
|
import { collectLastAssistantParts } from '../message-formatting.js'
|
|
16
16
|
import { createLogger } from '../logger.js'
|
|
17
|
+
import * as errore from 'errore'
|
|
17
18
|
|
|
18
19
|
const logger = createLogger('RESUME')
|
|
19
20
|
|
|
@@ -60,6 +61,10 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
|
|
|
60
61
|
|
|
61
62
|
try {
|
|
62
63
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
64
|
+
if (getClient instanceof Error) {
|
|
65
|
+
await command.editReply(getClient.message)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
63
68
|
|
|
64
69
|
const sessionResponse = await getClient().session.get({
|
|
65
70
|
path: { id: sessionId },
|
|
@@ -168,6 +173,10 @@ export async function handleResumeAutocomplete({
|
|
|
168
173
|
|
|
169
174
|
try {
|
|
170
175
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
176
|
+
if (getClient instanceof Error) {
|
|
177
|
+
await interaction.respond([])
|
|
178
|
+
return
|
|
179
|
+
}
|
|
171
180
|
|
|
172
181
|
const sessionsResponse = await getClient().session.list()
|
|
173
182
|
if (!sessionsResponse.data) {
|
package/src/commands/session.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// /session command - Start a new OpenCode session.
|
|
1
|
+
// /new-session command - Start a new OpenCode session.
|
|
2
2
|
|
|
3
3
|
import { ChannelType, type TextChannel } from 'discord.js'
|
|
4
4
|
import fs from 'node:fs'
|
|
@@ -10,6 +10,7 @@ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
|
10
10
|
import { extractTagsArrays } from '../xml.js'
|
|
11
11
|
import { handleOpencodeSession } from '../session-handler.js'
|
|
12
12
|
import { createLogger } from '../logger.js'
|
|
13
|
+
import * as errore from 'errore'
|
|
13
14
|
|
|
14
15
|
const logger = createLogger('SESSION')
|
|
15
16
|
|
|
@@ -58,6 +59,10 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
|
|
|
58
59
|
|
|
59
60
|
try {
|
|
60
61
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
62
|
+
if (getClient instanceof Error) {
|
|
63
|
+
await command.editReply(getClient.message)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
61
66
|
|
|
62
67
|
const files = filesString
|
|
63
68
|
.split(',')
|
|
@@ -128,6 +133,10 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
|
|
|
128
133
|
|
|
129
134
|
try {
|
|
130
135
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
136
|
+
if (getClient instanceof Error) {
|
|
137
|
+
await interaction.respond([])
|
|
138
|
+
return
|
|
139
|
+
}
|
|
131
140
|
|
|
132
141
|
const agentsResponse = await getClient().app.agents({
|
|
133
142
|
query: { directory: projectDirectory },
|
|
@@ -207,6 +216,10 @@ export async function handleSessionAutocomplete({
|
|
|
207
216
|
|
|
208
217
|
try {
|
|
209
218
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
219
|
+
if (getClient instanceof Error) {
|
|
220
|
+
await interaction.respond([])
|
|
221
|
+
return
|
|
222
|
+
}
|
|
210
223
|
|
|
211
224
|
const response = await getClient().find.files({
|
|
212
225
|
query: {
|
package/src/commands/share.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
|
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
7
7
|
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
8
8
|
import { createLogger } from '../logger.js'
|
|
9
|
+
import * as errore from 'errore'
|
|
9
10
|
|
|
10
11
|
const logger = createLogger('SHARE')
|
|
11
12
|
|
|
@@ -63,8 +64,17 @@ export async function handleShareCommand({ command }: CommandContext): Promise<v
|
|
|
63
64
|
|
|
64
65
|
const sessionId = row.session_id
|
|
65
66
|
|
|
67
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
68
|
+
if (getClient instanceof Error) {
|
|
69
|
+
await command.reply({
|
|
70
|
+
content: `Failed to share session: ${getClient.message}`,
|
|
71
|
+
ephemeral: true,
|
|
72
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
73
|
+
})
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
try {
|
|
67
|
-
const getClient = await initializeOpencodeForDirectory(directory)
|
|
68
78
|
const response = await getClient().session.share({
|
|
69
79
|
path: { id: sessionId },
|
|
70
80
|
})
|
|
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
|
|
|
6
6
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
7
7
|
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
8
8
|
import { createLogger } from '../logger.js'
|
|
9
|
+
import * as errore from 'errore'
|
|
9
10
|
|
|
10
11
|
const logger = createLogger('UNDO-REDO')
|
|
11
12
|
|
|
@@ -63,11 +64,15 @@ export async function handleUndoCommand({ command }: CommandContext): Promise<vo
|
|
|
63
64
|
|
|
64
65
|
const sessionId = row.session_id
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
67
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
70
|
+
if (getClient instanceof Error) {
|
|
71
|
+
await command.editReply(`Failed to undo: ${getClient.message}`)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
70
74
|
|
|
75
|
+
try {
|
|
71
76
|
// Fetch messages to find the last assistant message
|
|
72
77
|
const messagesResponse = await getClient().session.messages({
|
|
73
78
|
path: { id: sessionId },
|
|
@@ -166,11 +171,15 @@ export async function handleRedoCommand({ command }: CommandContext): Promise<vo
|
|
|
166
171
|
|
|
167
172
|
const sessionId = row.session_id
|
|
168
173
|
|
|
169
|
-
|
|
170
|
-
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
174
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
177
|
+
if (getClient instanceof Error) {
|
|
178
|
+
await command.editReply(`Failed to redo: ${getClient.message}`)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
173
181
|
|
|
182
|
+
try {
|
|
174
183
|
// Check if session has reverted state
|
|
175
184
|
const sessionResponse = await getClient().session.get({
|
|
176
185
|
path: { id: sessionId },
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Worktree management command: /new-worktree
|
|
2
|
+
// Uses OpenCode SDK v2 to create worktrees with kimaki- prefix
|
|
3
|
+
// Creates thread immediately, then worktree in background so user can type
|
|
4
|
+
|
|
5
|
+
import { ChannelType, type TextChannel, type ThreadChannel, type Message } from 'discord.js'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import type { CommandContext } from './types.js'
|
|
8
|
+
import {
|
|
9
|
+
createPendingWorktree,
|
|
10
|
+
setWorktreeReady,
|
|
11
|
+
setWorktreeError,
|
|
12
|
+
} from '../database.js'
|
|
13
|
+
import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js'
|
|
14
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
15
|
+
import { extractTagsArrays } from '../xml.js'
|
|
16
|
+
import { createLogger } from '../logger.js'
|
|
17
|
+
import * as errore from 'errore'
|
|
18
|
+
|
|
19
|
+
const logger = createLogger('WORKTREE')
|
|
20
|
+
|
|
21
|
+
class WorktreeError extends Error {
|
|
22
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
23
|
+
super(message, options)
|
|
24
|
+
this.name = 'WorktreeError'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format worktree name: lowercase, spaces to dashes, remove special chars, add kimaki- prefix.
|
|
30
|
+
* "My Feature" → "kimaki-my-feature"
|
|
31
|
+
*/
|
|
32
|
+
function formatWorktreeName(name: string): string {
|
|
33
|
+
const formatted = name
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/\s+/g, '-')
|
|
37
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
38
|
+
|
|
39
|
+
return `kimaki-${formatted}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get project directory from channel topic.
|
|
44
|
+
*/
|
|
45
|
+
function getProjectDirectoryFromChannel(
|
|
46
|
+
channel: TextChannel,
|
|
47
|
+
appId: string,
|
|
48
|
+
): string | WorktreeError {
|
|
49
|
+
if (!channel.topic) {
|
|
50
|
+
return new WorktreeError('This channel has no topic configured')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const extracted = extractTagsArrays({
|
|
54
|
+
xml: channel.topic,
|
|
55
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
59
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
60
|
+
|
|
61
|
+
if (channelAppId && channelAppId !== appId) {
|
|
62
|
+
return new WorktreeError('This channel is not configured for this bot')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!projectDirectory) {
|
|
66
|
+
return new WorktreeError('This channel is not configured with a project directory')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
70
|
+
return new WorktreeError(`Directory does not exist: ${projectDirectory}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return projectDirectory
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create worktree in background and update starter message when done.
|
|
78
|
+
*/
|
|
79
|
+
async function createWorktreeInBackground({
|
|
80
|
+
thread,
|
|
81
|
+
starterMessage,
|
|
82
|
+
worktreeName,
|
|
83
|
+
projectDirectory,
|
|
84
|
+
clientV2,
|
|
85
|
+
}: {
|
|
86
|
+
thread: ThreadChannel
|
|
87
|
+
starterMessage: Message
|
|
88
|
+
worktreeName: string
|
|
89
|
+
projectDirectory: string
|
|
90
|
+
clientV2: ReturnType<typeof getOpencodeClientV2> & {}
|
|
91
|
+
}): Promise<void> {
|
|
92
|
+
// Create worktree using SDK v2
|
|
93
|
+
logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}`)
|
|
94
|
+
const worktreeResult = await errore.tryAsync({
|
|
95
|
+
try: async () => {
|
|
96
|
+
const response = await clientV2.worktree.create({
|
|
97
|
+
directory: projectDirectory,
|
|
98
|
+
worktreeCreateInput: {
|
|
99
|
+
name: worktreeName,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (response.error) {
|
|
104
|
+
throw new Error(`SDK error: ${JSON.stringify(response.error)}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!response.data) {
|
|
108
|
+
throw new Error('No worktree data returned from SDK')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return response.data
|
|
112
|
+
},
|
|
113
|
+
catch: (e) => new WorktreeError('Failed to create worktree', { cause: e }),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (errore.isError(worktreeResult)) {
|
|
117
|
+
const errorMsg = worktreeResult.message
|
|
118
|
+
logger.error('[NEW-WORKTREE] Error:', worktreeResult.cause)
|
|
119
|
+
setWorktreeError({ threadId: thread.id, errorMessage: errorMsg })
|
|
120
|
+
await starterMessage.edit(`🌳 **Worktree: ${worktreeName}**\n❌ ${errorMsg}`)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Success - update database and edit starter message
|
|
125
|
+
setWorktreeReady({ threadId: thread.id, worktreeDirectory: worktreeResult.directory })
|
|
126
|
+
await starterMessage.edit(
|
|
127
|
+
`🌳 **Worktree: ${worktreeName}**\n` +
|
|
128
|
+
`📁 \`${worktreeResult.directory}\`\n` +
|
|
129
|
+
`🌿 Branch: \`${worktreeResult.branch}\``
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function handleNewWorktreeCommand({
|
|
134
|
+
command,
|
|
135
|
+
appId,
|
|
136
|
+
}: CommandContext): Promise<void> {
|
|
137
|
+
await command.deferReply({ ephemeral: false })
|
|
138
|
+
|
|
139
|
+
const rawName = command.options.getString('name', true)
|
|
140
|
+
const worktreeName = formatWorktreeName(rawName)
|
|
141
|
+
|
|
142
|
+
if (worktreeName === 'kimaki-') {
|
|
143
|
+
await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.')
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const channel = command.channel
|
|
148
|
+
|
|
149
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
150
|
+
await command.editReply('This command can only be used in text channels')
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const textChannel = channel as TextChannel
|
|
155
|
+
|
|
156
|
+
const projectDirectory = getProjectDirectoryFromChannel(textChannel, appId)
|
|
157
|
+
if (errore.isError(projectDirectory)) {
|
|
158
|
+
await command.editReply(projectDirectory.message)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Initialize opencode and check if worktree already exists
|
|
163
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
164
|
+
if (errore.isError(getClient)) {
|
|
165
|
+
await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const clientV2 = getOpencodeClientV2(projectDirectory)
|
|
170
|
+
if (!clientV2) {
|
|
171
|
+
await command.editReply('Failed to get OpenCode client')
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check if worktree with this name already exists
|
|
176
|
+
// SDK returns array of directory paths like "~/.opencode/worktree/abc/kimaki-my-feature"
|
|
177
|
+
const listResult = await errore.tryAsync({
|
|
178
|
+
try: async () => {
|
|
179
|
+
const response = await clientV2.worktree.list({ directory: projectDirectory })
|
|
180
|
+
return response.data || []
|
|
181
|
+
},
|
|
182
|
+
catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
if (errore.isError(listResult)) {
|
|
186
|
+
await command.editReply(listResult.message)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if any worktree path ends with our name
|
|
191
|
+
const existingWorktree = listResult.find((dir) => dir.endsWith(`/${worktreeName}`))
|
|
192
|
+
if (existingWorktree) {
|
|
193
|
+
await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktree}\``)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Create thread immediately so user can start typing
|
|
198
|
+
const result = await errore.tryAsync({
|
|
199
|
+
try: async () => {
|
|
200
|
+
const starterMessage = await textChannel.send({
|
|
201
|
+
content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
|
|
202
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const thread = await starterMessage.startThread({
|
|
206
|
+
name: `worktree: ${worktreeName}`,
|
|
207
|
+
autoArchiveDuration: 1440,
|
|
208
|
+
reason: 'Worktree session',
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
return { thread, starterMessage }
|
|
212
|
+
},
|
|
213
|
+
catch: (e) => new WorktreeError('Failed to create thread', { cause: e }),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
if (errore.isError(result)) {
|
|
217
|
+
logger.error('[NEW-WORKTREE] Error:', result.cause)
|
|
218
|
+
await command.editReply(result.message)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { thread, starterMessage } = result
|
|
223
|
+
|
|
224
|
+
// Store pending worktree in database
|
|
225
|
+
createPendingWorktree({
|
|
226
|
+
threadId: thread.id,
|
|
227
|
+
worktreeName,
|
|
228
|
+
projectDirectory,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
await command.editReply(`Creating worktree in ${thread.toString()}`)
|
|
232
|
+
|
|
233
|
+
// Create worktree in background (don't await)
|
|
234
|
+
createWorktreeInBackground({
|
|
235
|
+
thread,
|
|
236
|
+
starterMessage,
|
|
237
|
+
worktreeName,
|
|
238
|
+
projectDirectory,
|
|
239
|
+
clientV2,
|
|
240
|
+
}).catch((e) => {
|
|
241
|
+
logger.error('[NEW-WORKTREE] Background error:', e)
|
|
242
|
+
})
|
|
243
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import Database from 'better-sqlite3'
|
|
6
6
|
import fs from 'node:fs'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
+
import * as errore from 'errore'
|
|
8
9
|
import { createLogger } from './logger.js'
|
|
9
10
|
import { getDataDir } from './config.js'
|
|
10
11
|
|
|
@@ -16,10 +17,14 @@ export function getDatabase(): Database.Database {
|
|
|
16
17
|
if (!db) {
|
|
17
18
|
const dataDir = getDataDir()
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const mkdirError = errore.tryFn({
|
|
21
|
+
try: () => {
|
|
22
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
23
|
+
},
|
|
24
|
+
catch: (e) => e as Error,
|
|
25
|
+
})
|
|
26
|
+
if (mkdirError instanceof Error) {
|
|
27
|
+
dbLogger.error(`Failed to create data directory ${dataDir}:`, mkdirError.message)
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
const dbPath = path.join(dataDir, 'discord-sessions.db')
|
|
@@ -85,6 +90,20 @@ export function getDatabase(): Database.Database {
|
|
|
85
90
|
)
|
|
86
91
|
`)
|
|
87
92
|
|
|
93
|
+
// Track worktrees created for threads (for /new-worktree command)
|
|
94
|
+
// status: 'pending' while creating, 'ready' when done, 'error' if failed
|
|
95
|
+
db.exec(`
|
|
96
|
+
CREATE TABLE IF NOT EXISTS thread_worktrees (
|
|
97
|
+
thread_id TEXT PRIMARY KEY,
|
|
98
|
+
worktree_name TEXT NOT NULL,
|
|
99
|
+
worktree_directory TEXT,
|
|
100
|
+
project_directory TEXT NOT NULL,
|
|
101
|
+
status TEXT DEFAULT 'pending',
|
|
102
|
+
error_message TEXT,
|
|
103
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
104
|
+
)
|
|
105
|
+
`)
|
|
106
|
+
|
|
88
107
|
runModelMigrations(db)
|
|
89
108
|
}
|
|
90
109
|
|
|
@@ -238,6 +257,87 @@ export function setSessionAgent(sessionId: string, agentName: string): void {
|
|
|
238
257
|
)
|
|
239
258
|
}
|
|
240
259
|
|
|
260
|
+
// Worktree status types
|
|
261
|
+
export type WorktreeStatus = 'pending' | 'ready' | 'error'
|
|
262
|
+
|
|
263
|
+
export type ThreadWorktree = {
|
|
264
|
+
thread_id: string
|
|
265
|
+
worktree_name: string
|
|
266
|
+
worktree_directory: string | null
|
|
267
|
+
project_directory: string
|
|
268
|
+
status: WorktreeStatus
|
|
269
|
+
error_message: string | null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get the worktree info for a thread.
|
|
274
|
+
*/
|
|
275
|
+
export function getThreadWorktree(threadId: string): ThreadWorktree | undefined {
|
|
276
|
+
const db = getDatabase()
|
|
277
|
+
return db.prepare('SELECT * FROM thread_worktrees WHERE thread_id = ?').get(threadId) as
|
|
278
|
+
| ThreadWorktree
|
|
279
|
+
| undefined
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Create a pending worktree entry for a thread.
|
|
284
|
+
*/
|
|
285
|
+
export function createPendingWorktree({
|
|
286
|
+
threadId,
|
|
287
|
+
worktreeName,
|
|
288
|
+
projectDirectory,
|
|
289
|
+
}: {
|
|
290
|
+
threadId: string
|
|
291
|
+
worktreeName: string
|
|
292
|
+
projectDirectory: string
|
|
293
|
+
}): void {
|
|
294
|
+
const db = getDatabase()
|
|
295
|
+
db.prepare(
|
|
296
|
+
`INSERT OR REPLACE INTO thread_worktrees (thread_id, worktree_name, project_directory, status) VALUES (?, ?, ?, 'pending')`,
|
|
297
|
+
).run(threadId, worktreeName, projectDirectory)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Mark a worktree as ready with its directory.
|
|
302
|
+
*/
|
|
303
|
+
export function setWorktreeReady({
|
|
304
|
+
threadId,
|
|
305
|
+
worktreeDirectory,
|
|
306
|
+
}: {
|
|
307
|
+
threadId: string
|
|
308
|
+
worktreeDirectory: string
|
|
309
|
+
}): void {
|
|
310
|
+
const db = getDatabase()
|
|
311
|
+
db.prepare(
|
|
312
|
+
`UPDATE thread_worktrees SET worktree_directory = ?, status = 'ready' WHERE thread_id = ?`,
|
|
313
|
+
).run(worktreeDirectory, threadId)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Mark a worktree as failed with error message.
|
|
318
|
+
*/
|
|
319
|
+
export function setWorktreeError({
|
|
320
|
+
threadId,
|
|
321
|
+
errorMessage,
|
|
322
|
+
}: {
|
|
323
|
+
threadId: string
|
|
324
|
+
errorMessage: string
|
|
325
|
+
}): void {
|
|
326
|
+
const db = getDatabase()
|
|
327
|
+
db.prepare(`UPDATE thread_worktrees SET status = 'error', error_message = ? WHERE thread_id = ?`).run(
|
|
328
|
+
errorMessage,
|
|
329
|
+
threadId,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Delete the worktree info for a thread.
|
|
335
|
+
*/
|
|
336
|
+
export function deleteThreadWorktree(threadId: string): void {
|
|
337
|
+
const db = getDatabase()
|
|
338
|
+
db.prepare('DELETE FROM thread_worktrees WHERE thread_id = ?').run(threadId)
|
|
339
|
+
}
|
|
340
|
+
|
|
241
341
|
export function closeDatabase(): void {
|
|
242
342
|
if (db) {
|
|
243
343
|
db.close()
|