kimaki 0.4.25 → 0.4.27
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/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +58 -18
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +184 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/permissions.js +101 -105
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +25 -8
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +142 -66
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +73 -19
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +277 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/permissions.ts +139 -114
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +31 -10
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +190 -97
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// User-defined OpenCode command handler.
|
|
2
|
+
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
|
+
|
|
4
|
+
import type { CommandContext, CommandHandler } from './types.js'
|
|
5
|
+
import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
|
|
6
|
+
import { extractTagsArrays } from '../xml.js'
|
|
7
|
+
import { handleOpencodeSession } from '../session-handler.js'
|
|
8
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
9
|
+
import { createLogger } from '../logger.js'
|
|
10
|
+
import { getDatabase } from '../database.js'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
|
|
13
|
+
const userCommandLogger = createLogger('USER_CMD')
|
|
14
|
+
|
|
15
|
+
export const handleUserCommand: CommandHandler = async ({
|
|
16
|
+
command,
|
|
17
|
+
appId,
|
|
18
|
+
}: CommandContext) => {
|
|
19
|
+
const discordCommandName = command.commandName
|
|
20
|
+
// Strip the -cmd suffix to get the actual OpenCode command name
|
|
21
|
+
const commandName = discordCommandName.replace(/-cmd$/, '')
|
|
22
|
+
const args = command.options.getString('arguments') || ''
|
|
23
|
+
|
|
24
|
+
userCommandLogger.log(
|
|
25
|
+
`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const channel = command.channel
|
|
29
|
+
|
|
30
|
+
userCommandLogger.log(
|
|
31
|
+
`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const isThread = channel && [
|
|
35
|
+
ChannelType.PublicThread,
|
|
36
|
+
ChannelType.PrivateThread,
|
|
37
|
+
ChannelType.AnnouncementThread,
|
|
38
|
+
].includes(channel.type)
|
|
39
|
+
|
|
40
|
+
const isTextChannel = channel?.type === ChannelType.GuildText
|
|
41
|
+
|
|
42
|
+
if (!channel || (!isTextChannel && !isThread)) {
|
|
43
|
+
await command.reply({
|
|
44
|
+
content: 'This command can only be used in text channels or threads',
|
|
45
|
+
ephemeral: true,
|
|
46
|
+
})
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let projectDirectory: string | undefined
|
|
51
|
+
let channelAppId: string | undefined
|
|
52
|
+
let textChannel: TextChannel | null = null
|
|
53
|
+
let thread: ThreadChannel | null = null
|
|
54
|
+
|
|
55
|
+
if (isThread) {
|
|
56
|
+
// Running in an existing thread - get project directory from parent channel
|
|
57
|
+
thread = channel as ThreadChannel
|
|
58
|
+
textChannel = thread.parent as TextChannel | null
|
|
59
|
+
|
|
60
|
+
// Verify this thread has an existing session
|
|
61
|
+
const row = getDatabase()
|
|
62
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
63
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
64
|
+
|
|
65
|
+
if (!row) {
|
|
66
|
+
await command.reply({
|
|
67
|
+
content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
|
|
68
|
+
ephemeral: true,
|
|
69
|
+
})
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (textChannel?.topic) {
|
|
74
|
+
const extracted = extractTagsArrays({
|
|
75
|
+
xml: textChannel.topic,
|
|
76
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
80
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Running in a text channel - will create a new thread
|
|
84
|
+
textChannel = channel as TextChannel
|
|
85
|
+
|
|
86
|
+
if (textChannel.topic) {
|
|
87
|
+
const extracted = extractTagsArrays({
|
|
88
|
+
xml: textChannel.topic,
|
|
89
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
93
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (channelAppId && channelAppId !== appId) {
|
|
98
|
+
await command.reply({
|
|
99
|
+
content: 'This channel is not configured for this bot',
|
|
100
|
+
ephemeral: true,
|
|
101
|
+
})
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!projectDirectory) {
|
|
106
|
+
await command.reply({
|
|
107
|
+
content: 'This channel is not configured with a project directory',
|
|
108
|
+
ephemeral: true,
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
114
|
+
await command.reply({
|
|
115
|
+
content: `Directory does not exist: ${projectDirectory}`,
|
|
116
|
+
ephemeral: true,
|
|
117
|
+
})
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await command.deferReply({ ephemeral: false })
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// Use the dedicated session.command API instead of formatting as text prompt
|
|
125
|
+
const commandPayload = { name: commandName, arguments: args }
|
|
126
|
+
|
|
127
|
+
if (isThread && thread) {
|
|
128
|
+
// Running in existing thread - just send the command
|
|
129
|
+
await command.editReply(`Running /${commandName}...`)
|
|
130
|
+
|
|
131
|
+
await handleOpencodeSession({
|
|
132
|
+
prompt: '', // Not used when command is set
|
|
133
|
+
thread,
|
|
134
|
+
projectDirectory,
|
|
135
|
+
channelId: textChannel?.id,
|
|
136
|
+
command: commandPayload,
|
|
137
|
+
})
|
|
138
|
+
} else if (textChannel) {
|
|
139
|
+
// Running in text channel - create a new thread
|
|
140
|
+
const starterMessage = await textChannel.send({
|
|
141
|
+
content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
|
|
142
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`
|
|
146
|
+
const newThread = await starterMessage.startThread({
|
|
147
|
+
name: threadName.slice(0, 100),
|
|
148
|
+
autoArchiveDuration: 1440,
|
|
149
|
+
reason: `OpenCode command: ${commandName}`,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await command.editReply(`Started /${commandName} in ${newThread.toString()}`)
|
|
153
|
+
|
|
154
|
+
await handleOpencodeSession({
|
|
155
|
+
prompt: '', // Not used when command is set
|
|
156
|
+
thread: newThread,
|
|
157
|
+
projectDirectory,
|
|
158
|
+
channelId: textChannel.id,
|
|
159
|
+
command: commandPayload,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
userCommandLogger.error(`Error executing /${commandName}:`, error)
|
|
164
|
+
|
|
165
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
166
|
+
|
|
167
|
+
if (command.deferred) {
|
|
168
|
+
await command.editReply({
|
|
169
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
170
|
+
})
|
|
171
|
+
} else {
|
|
172
|
+
await command.reply({
|
|
173
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
174
|
+
ephemeral: true,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -100,6 +100,23 @@ export function runModelMigrations(database?: Database.Database): void {
|
|
|
100
100
|
)
|
|
101
101
|
`)
|
|
102
102
|
|
|
103
|
+
targetDb.exec(`
|
|
104
|
+
CREATE TABLE IF NOT EXISTS channel_agents (
|
|
105
|
+
channel_id TEXT PRIMARY KEY,
|
|
106
|
+
agent_name TEXT NOT NULL,
|
|
107
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
108
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
109
|
+
)
|
|
110
|
+
`)
|
|
111
|
+
|
|
112
|
+
targetDb.exec(`
|
|
113
|
+
CREATE TABLE IF NOT EXISTS session_agents (
|
|
114
|
+
session_id TEXT PRIMARY KEY,
|
|
115
|
+
agent_name TEXT NOT NULL,
|
|
116
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
117
|
+
)
|
|
118
|
+
`)
|
|
119
|
+
|
|
103
120
|
dbLogger.log('Model preferences migrations complete')
|
|
104
121
|
}
|
|
105
122
|
|
|
@@ -151,6 +168,50 @@ export function setSessionModel(sessionId: string, modelId: string): void {
|
|
|
151
168
|
).run(sessionId, modelId)
|
|
152
169
|
}
|
|
153
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Get the agent preference for a channel.
|
|
173
|
+
*/
|
|
174
|
+
export function getChannelAgent(channelId: string): string | undefined {
|
|
175
|
+
const db = getDatabase()
|
|
176
|
+
const row = db
|
|
177
|
+
.prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
|
|
178
|
+
.get(channelId) as { agent_name: string } | undefined
|
|
179
|
+
return row?.agent_name
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Set the agent preference for a channel.
|
|
184
|
+
*/
|
|
185
|
+
export function setChannelAgent(channelId: string, agentName: string): void {
|
|
186
|
+
const db = getDatabase()
|
|
187
|
+
db.prepare(
|
|
188
|
+
`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
|
|
189
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
190
|
+
ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`
|
|
191
|
+
).run(channelId, agentName, agentName)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the agent preference for a session.
|
|
196
|
+
*/
|
|
197
|
+
export function getSessionAgent(sessionId: string): string | undefined {
|
|
198
|
+
const db = getDatabase()
|
|
199
|
+
const row = db
|
|
200
|
+
.prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
|
|
201
|
+
.get(sessionId) as { agent_name: string } | undefined
|
|
202
|
+
return row?.agent_name
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Set the agent preference for a session.
|
|
207
|
+
*/
|
|
208
|
+
export function setSessionAgent(sessionId: string, agentName: string): void {
|
|
209
|
+
const db = getDatabase()
|
|
210
|
+
db.prepare(
|
|
211
|
+
`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`
|
|
212
|
+
).run(sessionId, agentName)
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
export function closeDatabase(): void {
|
|
155
216
|
if (db) {
|
|
156
217
|
db.close()
|
package/src/discord-bot.ts
CHANGED
|
@@ -25,9 +25,10 @@ import {
|
|
|
25
25
|
registerVoiceStateHandler,
|
|
26
26
|
} from './voice-handler.js'
|
|
27
27
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} from './
|
|
28
|
+
getCompactSessionContext,
|
|
29
|
+
getLastSessionId,
|
|
30
|
+
} from './markdown.js'
|
|
31
|
+
import { handleOpencodeSession } from './session-handler.js'
|
|
31
32
|
import { registerInteractionHandler } from './interaction-handler.js'
|
|
32
33
|
|
|
33
34
|
export { getDatabase, closeDatabase } from './database.js'
|
|
@@ -240,34 +241,39 @@ export async function startDiscordBot({
|
|
|
240
241
|
|
|
241
242
|
let messageContent = message.content || ''
|
|
242
243
|
|
|
243
|
-
let
|
|
244
|
-
|
|
244
|
+
let currentSessionContext: string | undefined
|
|
245
|
+
let lastSessionContext: string | undefined
|
|
246
|
+
|
|
247
|
+
if (projectDirectory) {
|
|
245
248
|
try {
|
|
246
249
|
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
247
|
-
const
|
|
248
|
-
|
|
250
|
+
const client = getClient()
|
|
251
|
+
|
|
252
|
+
// get current session context (without system prompt, it would be duplicated)
|
|
253
|
+
if (row.session_id) {
|
|
254
|
+
currentSessionContext = await getCompactSessionContext({
|
|
255
|
+
client,
|
|
256
|
+
sessionId: row.session_id,
|
|
257
|
+
includeSystemPrompt: false,
|
|
258
|
+
maxMessages: 15,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// get last session context (with system prompt for project context)
|
|
263
|
+
const lastSessionId = await getLastSessionId({
|
|
264
|
+
client,
|
|
265
|
+
excludeSessionId: row.session_id,
|
|
249
266
|
})
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (m.info.role === 'user') {
|
|
257
|
-
const textParts = (m.parts || []).filter((p) => p.type === 'text')
|
|
258
|
-
return textParts
|
|
259
|
-
.map((p) => ('text' in p ? p.text : ''))
|
|
260
|
-
.filter(Boolean)
|
|
261
|
-
.join('\n')
|
|
262
|
-
}
|
|
263
|
-
const assistantInfo = m.info as { text?: string }
|
|
264
|
-
return assistantInfo.text?.slice(0, 500)
|
|
265
|
-
})()
|
|
266
|
-
return `[${role}]: ${text || '(no text)'}`
|
|
267
|
+
if (lastSessionId) {
|
|
268
|
+
lastSessionContext = await getCompactSessionContext({
|
|
269
|
+
client,
|
|
270
|
+
sessionId: lastSessionId,
|
|
271
|
+
includeSystemPrompt: true,
|
|
272
|
+
maxMessages: 10,
|
|
267
273
|
})
|
|
268
|
-
|
|
274
|
+
}
|
|
269
275
|
} catch (e) {
|
|
270
|
-
voiceLogger.
|
|
276
|
+
voiceLogger.error(`Could not get session context:`, e)
|
|
271
277
|
}
|
|
272
278
|
}
|
|
273
279
|
|
|
@@ -276,25 +282,24 @@ export async function startDiscordBot({
|
|
|
276
282
|
thread,
|
|
277
283
|
projectDirectory,
|
|
278
284
|
appId: currentAppId,
|
|
279
|
-
|
|
285
|
+
currentSessionContext,
|
|
286
|
+
lastSessionContext,
|
|
280
287
|
})
|
|
281
288
|
if (transcription) {
|
|
282
289
|
messageContent = transcription
|
|
283
290
|
}
|
|
284
291
|
|
|
285
|
-
const fileAttachments = getFileAttachments(message)
|
|
292
|
+
const fileAttachments = await getFileAttachments(message)
|
|
286
293
|
const textAttachmentsContent = await getTextAttachments(message)
|
|
287
294
|
const promptWithAttachments = textAttachmentsContent
|
|
288
295
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
289
296
|
: messageContent
|
|
290
|
-
const parsedCommand = parseSlashCommand(messageContent)
|
|
291
297
|
await handleOpencodeSession({
|
|
292
298
|
prompt: promptWithAttachments,
|
|
293
299
|
thread,
|
|
294
300
|
projectDirectory,
|
|
295
301
|
originalMessage: message,
|
|
296
302
|
images: fileAttachments,
|
|
297
|
-
parsedCommand,
|
|
298
303
|
channelId: parent?.id,
|
|
299
304
|
})
|
|
300
305
|
return
|
|
@@ -380,19 +385,17 @@ export async function startDiscordBot({
|
|
|
380
385
|
messageContent = transcription
|
|
381
386
|
}
|
|
382
387
|
|
|
383
|
-
const fileAttachments = getFileAttachments(message)
|
|
388
|
+
const fileAttachments = await getFileAttachments(message)
|
|
384
389
|
const textAttachmentsContent = await getTextAttachments(message)
|
|
385
390
|
const promptWithAttachments = textAttachmentsContent
|
|
386
391
|
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
387
392
|
: messageContent
|
|
388
|
-
const parsedCommand = parseSlashCommand(messageContent)
|
|
389
393
|
await handleOpencodeSession({
|
|
390
394
|
prompt: promptWithAttachments,
|
|
391
395
|
thread,
|
|
392
396
|
projectDirectory,
|
|
393
397
|
originalMessage: message,
|
|
394
398
|
images: fileAttachments,
|
|
395
|
-
parsedCommand,
|
|
396
399
|
channelId: textChannel.id,
|
|
397
400
|
})
|
|
398
401
|
} else {
|
package/src/discord-utils.ts
CHANGED
|
@@ -85,31 +85,93 @@ export function splitMarkdownForDiscord({
|
|
|
85
85
|
let currentChunk = ''
|
|
86
86
|
let currentLang: string | null = null
|
|
87
87
|
|
|
88
|
+
// helper to split a long line into smaller pieces at word boundaries or hard breaks
|
|
89
|
+
const splitLongLine = (text: string, available: number, inCode: boolean): string[] => {
|
|
90
|
+
const pieces: string[] = []
|
|
91
|
+
let remaining = text
|
|
92
|
+
|
|
93
|
+
while (remaining.length > available) {
|
|
94
|
+
let splitAt = available
|
|
95
|
+
// for non-code, try to split at word boundary
|
|
96
|
+
if (!inCode) {
|
|
97
|
+
const lastSpace = remaining.lastIndexOf(' ', available)
|
|
98
|
+
if (lastSpace > available * 0.5) {
|
|
99
|
+
splitAt = lastSpace + 1
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
pieces.push(remaining.slice(0, splitAt))
|
|
103
|
+
remaining = remaining.slice(splitAt)
|
|
104
|
+
}
|
|
105
|
+
if (remaining) {
|
|
106
|
+
pieces.push(remaining)
|
|
107
|
+
}
|
|
108
|
+
return pieces
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
for (const line of lines) {
|
|
89
112
|
const wouldExceed = currentChunk.length + line.text.length > maxLength
|
|
90
113
|
|
|
91
|
-
if (wouldExceed
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
114
|
+
if (wouldExceed) {
|
|
115
|
+
// handle case where single line is longer than maxLength
|
|
116
|
+
if (line.text.length > maxLength) {
|
|
117
|
+
// first, flush current chunk if any
|
|
118
|
+
if (currentChunk) {
|
|
119
|
+
if (currentLang !== null) {
|
|
120
|
+
currentChunk += '```\n'
|
|
121
|
+
}
|
|
122
|
+
chunks.push(currentChunk)
|
|
123
|
+
currentChunk = ''
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// calculate overhead for code block markers
|
|
127
|
+
const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
|
|
128
|
+
const availablePerChunk = maxLength - codeBlockOverhead - 50 // safety margin
|
|
129
|
+
|
|
130
|
+
const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
133
|
+
const piece = pieces[i]!
|
|
134
|
+
if (line.inCodeBlock) {
|
|
135
|
+
chunks.push('```' + line.lang + '\n' + piece + '```\n')
|
|
136
|
+
} else {
|
|
137
|
+
chunks.push(piece)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
96
140
|
|
|
97
|
-
if (line.isClosingFence && currentLang !== null) {
|
|
98
|
-
currentChunk = ''
|
|
99
141
|
currentLang = null
|
|
100
142
|
continue
|
|
101
143
|
}
|
|
102
144
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
145
|
+
// normal case: line fits in a chunk but current chunk would overflow
|
|
146
|
+
if (currentChunk) {
|
|
147
|
+
if (currentLang !== null) {
|
|
148
|
+
currentChunk += '```\n'
|
|
149
|
+
}
|
|
150
|
+
chunks.push(currentChunk)
|
|
151
|
+
|
|
152
|
+
if (line.isClosingFence && currentLang !== null) {
|
|
153
|
+
currentChunk = ''
|
|
154
|
+
currentLang = null
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
159
|
+
const lang = line.lang
|
|
160
|
+
currentChunk = '```' + lang + '\n'
|
|
161
|
+
if (!line.isOpeningFence) {
|
|
162
|
+
currentChunk += line.text
|
|
163
|
+
}
|
|
164
|
+
currentLang = lang
|
|
165
|
+
} else {
|
|
166
|
+
currentChunk = line.text
|
|
167
|
+
currentLang = null
|
|
108
168
|
}
|
|
109
|
-
currentLang = lang
|
|
110
169
|
} else {
|
|
170
|
+
// currentChunk is empty but line still exceeds - shouldn't happen after above check
|
|
111
171
|
currentChunk = line.text
|
|
112
|
-
|
|
172
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
173
|
+
currentLang = line.lang
|
|
174
|
+
}
|
|
113
175
|
}
|
|
114
176
|
} else {
|
|
115
177
|
currentChunk += line.text
|
|
@@ -7,17 +7,21 @@ import { handleSessionCommand, handleSessionAutocomplete } from './commands/sess
|
|
|
7
7
|
import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js'
|
|
8
8
|
import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js'
|
|
9
9
|
import { handleCreateNewProjectCommand } from './commands/create-new-project.js'
|
|
10
|
-
import {
|
|
10
|
+
import { handlePermissionSelectMenu } from './commands/permissions.js'
|
|
11
11
|
import { handleAbortCommand } from './commands/abort.js'
|
|
12
12
|
import { handleShareCommand } from './commands/share.js'
|
|
13
13
|
import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js'
|
|
14
14
|
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu } from './commands/model.js'
|
|
15
|
+
import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js'
|
|
16
|
+
import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
|
|
15
17
|
import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
|
|
16
18
|
import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
|
|
19
|
+
import { handleUserCommand } from './commands/user-command.js'
|
|
17
20
|
import { createLogger } from './logger.js'
|
|
18
21
|
|
|
19
22
|
const interactionLogger = createLogger('INTERACTION')
|
|
20
23
|
|
|
24
|
+
|
|
21
25
|
export function registerInteractionHandler({
|
|
22
26
|
discordClient,
|
|
23
27
|
appId,
|
|
@@ -81,16 +85,8 @@ export function registerInteractionHandler({
|
|
|
81
85
|
await handleCreateNewProjectCommand({ command: interaction, appId })
|
|
82
86
|
return
|
|
83
87
|
|
|
84
|
-
case 'accept':
|
|
85
|
-
case 'accept-always':
|
|
86
|
-
await handleAcceptCommand({ command: interaction, appId })
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
case 'reject':
|
|
90
|
-
await handleRejectCommand({ command: interaction, appId })
|
|
91
|
-
return
|
|
92
|
-
|
|
93
88
|
case 'abort':
|
|
89
|
+
case 'stop':
|
|
94
90
|
await handleAbortCommand({ command: interaction, appId })
|
|
95
91
|
return
|
|
96
92
|
|
|
@@ -106,6 +102,10 @@ export function registerInteractionHandler({
|
|
|
106
102
|
await handleModelCommand({ interaction, appId })
|
|
107
103
|
return
|
|
108
104
|
|
|
105
|
+
case 'agent':
|
|
106
|
+
await handleAgentCommand({ interaction, appId })
|
|
107
|
+
return
|
|
108
|
+
|
|
109
109
|
case 'queue':
|
|
110
110
|
await handleQueueCommand({ command: interaction, appId })
|
|
111
111
|
return
|
|
@@ -122,6 +122,12 @@ export function registerInteractionHandler({
|
|
|
122
122
|
await handleRedoCommand({ command: interaction, appId })
|
|
123
123
|
return
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
// Handle user-defined commands (ending with -cmd suffix)
|
|
127
|
+
if (interaction.commandName.endsWith('-cmd')) {
|
|
128
|
+
await handleUserCommand({ command: interaction, appId })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
125
131
|
return
|
|
126
132
|
}
|
|
127
133
|
|
|
@@ -142,6 +148,21 @@ export function registerInteractionHandler({
|
|
|
142
148
|
await handleModelSelectMenu(interaction)
|
|
143
149
|
return
|
|
144
150
|
}
|
|
151
|
+
|
|
152
|
+
if (customId.startsWith('agent_select:')) {
|
|
153
|
+
await handleAgentSelectMenu(interaction)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (customId.startsWith('ask_question:')) {
|
|
158
|
+
await handleAskQuestionSelectMenu(interaction)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (customId.startsWith('permission:')) {
|
|
163
|
+
await handlePermissionSelectMenu(interaction)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
145
166
|
return
|
|
146
167
|
}
|
|
147
168
|
} catch (error) {
|
package/src/logger.ts
CHANGED
|
@@ -3,18 +3,55 @@
|
|
|
3
3
|
// (DISCORD, VOICE, SESSION, etc.) for easier debugging.
|
|
4
4
|
|
|
5
5
|
import { log } from '@clack/prompts'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path, { dirname } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const __dirname = dirname(__filename)
|
|
12
|
+
const isDev = !__dirname.includes('node_modules')
|
|
13
|
+
|
|
14
|
+
const logFilePath = path.join(__dirname, '..', 'tmp', 'kimaki.log')
|
|
15
|
+
|
|
16
|
+
// reset log file on startup in dev mode
|
|
17
|
+
if (isDev) {
|
|
18
|
+
const logDir = path.dirname(logFilePath)
|
|
19
|
+
if (!fs.existsSync(logDir)) {
|
|
20
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
21
|
+
}
|
|
22
|
+
fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeToFile(level: string, prefix: string, args: any[]) {
|
|
26
|
+
if (!isDev) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
const timestamp = new Date().toISOString()
|
|
30
|
+
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map((arg) => String(arg)).join(' ')}\n`
|
|
31
|
+
fs.appendFileSync(logFilePath, message)
|
|
32
|
+
}
|
|
6
33
|
|
|
7
34
|
export function createLogger(prefix: string) {
|
|
8
35
|
return {
|
|
9
|
-
log: (...args: any[]) =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
36
|
+
log: (...args: any[]) => {
|
|
37
|
+
writeToFile('INFO', prefix, args)
|
|
38
|
+
log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
|
|
39
|
+
},
|
|
40
|
+
error: (...args: any[]) => {
|
|
41
|
+
writeToFile('ERROR', prefix, args)
|
|
42
|
+
log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
|
|
43
|
+
},
|
|
44
|
+
warn: (...args: any[]) => {
|
|
45
|
+
writeToFile('WARN', prefix, args)
|
|
46
|
+
log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
|
|
47
|
+
},
|
|
48
|
+
info: (...args: any[]) => {
|
|
49
|
+
writeToFile('INFO', prefix, args)
|
|
50
|
+
log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
|
|
51
|
+
},
|
|
52
|
+
debug: (...args: any[]) => {
|
|
53
|
+
writeToFile('DEBUG', prefix, args)
|
|
54
|
+
log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
|
|
55
|
+
},
|
|
19
56
|
}
|
|
20
57
|
}
|