kimaki 0.4.22 → 0.4.23
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/channel-management.js +92 -0
- package/dist/cli.js +9 -1
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/message-formatting.js +188 -0
- package/dist/model-command.js +293 -0
- package/dist/opencode.js +135 -0
- package/dist/session-handler.js +467 -0
- package/dist/system-message.js +92 -0
- package/dist/tools.js +1 -1
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -1
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +9 -1
- package/src/database.ts +155 -0
- package/src/discord-bot.ts +506 -0
- package/src/discord-utils.ts +208 -0
- package/src/escape-backticks.test.ts +1 -1
- package/src/fork.ts +224 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/message-formatting.ts +227 -0
- package/src/model-command.ts +380 -0
- package/src/opencode.ts +180 -0
- package/src/session-handler.ts +601 -0
- package/src/system-message.ts +92 -0
- package/src/tools.ts +1 -1
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3671
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ChannelType, } from 'discord.js';
|
|
2
|
+
import { Lexer } from 'marked';
|
|
3
|
+
import { extractTagsArrays } from './xml.js';
|
|
4
|
+
import { formatMarkdownTables } from './format-tables.js';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
const discordLogger = createLogger('DISCORD');
|
|
7
|
+
export const SILENT_MESSAGE_FLAGS = 4 | 4096;
|
|
8
|
+
export function escapeBackticksInCodeBlocks(markdown) {
|
|
9
|
+
const lexer = new Lexer();
|
|
10
|
+
const tokens = lexer.lex(markdown);
|
|
11
|
+
let result = '';
|
|
12
|
+
for (const token of tokens) {
|
|
13
|
+
if (token.type === 'code') {
|
|
14
|
+
const escapedCode = token.text.replace(/`/g, '\\`');
|
|
15
|
+
result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n';
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
result += token.raw;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
export function splitMarkdownForDiscord({ content, maxLength, }) {
|
|
24
|
+
if (content.length <= maxLength) {
|
|
25
|
+
return [content];
|
|
26
|
+
}
|
|
27
|
+
const lexer = new Lexer();
|
|
28
|
+
const tokens = lexer.lex(content);
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const token of tokens) {
|
|
31
|
+
if (token.type === 'code') {
|
|
32
|
+
const lang = token.lang || '';
|
|
33
|
+
lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false });
|
|
34
|
+
const codeLines = token.text.split('\n');
|
|
35
|
+
for (const codeLine of codeLines) {
|
|
36
|
+
lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false });
|
|
37
|
+
}
|
|
38
|
+
lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const rawLines = token.raw.split('\n');
|
|
42
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
43
|
+
const isLast = i === rawLines.length - 1;
|
|
44
|
+
const text = isLast ? rawLines[i] : rawLines[i] + '\n';
|
|
45
|
+
if (text) {
|
|
46
|
+
lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let currentChunk = '';
|
|
53
|
+
let currentLang = null;
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const wouldExceed = currentChunk.length + line.text.length > maxLength;
|
|
56
|
+
if (wouldExceed && currentChunk) {
|
|
57
|
+
if (currentLang !== null) {
|
|
58
|
+
currentChunk += '```\n';
|
|
59
|
+
}
|
|
60
|
+
chunks.push(currentChunk);
|
|
61
|
+
if (line.isClosingFence && currentLang !== null) {
|
|
62
|
+
currentChunk = '';
|
|
63
|
+
currentLang = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
67
|
+
const lang = line.lang;
|
|
68
|
+
currentChunk = '```' + lang + '\n';
|
|
69
|
+
if (!line.isOpeningFence) {
|
|
70
|
+
currentChunk += line.text;
|
|
71
|
+
}
|
|
72
|
+
currentLang = lang;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
currentChunk = line.text;
|
|
76
|
+
currentLang = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
currentChunk += line.text;
|
|
81
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
82
|
+
currentLang = line.lang;
|
|
83
|
+
}
|
|
84
|
+
else if (line.isClosingFence) {
|
|
85
|
+
currentLang = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (currentChunk) {
|
|
90
|
+
chunks.push(currentChunk);
|
|
91
|
+
}
|
|
92
|
+
return chunks;
|
|
93
|
+
}
|
|
94
|
+
export async function sendThreadMessage(thread, content) {
|
|
95
|
+
const MAX_LENGTH = 2000;
|
|
96
|
+
content = formatMarkdownTables(content);
|
|
97
|
+
content = escapeBackticksInCodeBlocks(content);
|
|
98
|
+
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
|
|
99
|
+
if (chunks.length > 1) {
|
|
100
|
+
discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
|
|
101
|
+
}
|
|
102
|
+
let firstMessage;
|
|
103
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
104
|
+
const chunk = chunks[i];
|
|
105
|
+
if (!chunk) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const message = await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
109
|
+
if (i === 0) {
|
|
110
|
+
firstMessage = message;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return firstMessage;
|
|
114
|
+
}
|
|
115
|
+
export async function resolveTextChannel(channel) {
|
|
116
|
+
if (!channel) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (channel.type === ChannelType.GuildText) {
|
|
120
|
+
return channel;
|
|
121
|
+
}
|
|
122
|
+
if (channel.type === ChannelType.PublicThread ||
|
|
123
|
+
channel.type === ChannelType.PrivateThread ||
|
|
124
|
+
channel.type === ChannelType.AnnouncementThread) {
|
|
125
|
+
const parentId = channel.parentId;
|
|
126
|
+
if (parentId) {
|
|
127
|
+
const parent = await channel.guild.channels.fetch(parentId);
|
|
128
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
129
|
+
return parent;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
export function escapeDiscordFormatting(text) {
|
|
136
|
+
return text
|
|
137
|
+
.replace(/```/g, '\\`\\`\\`')
|
|
138
|
+
.replace(/````/g, '\\`\\`\\`\\`');
|
|
139
|
+
}
|
|
140
|
+
export function getKimakiMetadata(textChannel) {
|
|
141
|
+
if (!textChannel?.topic) {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
const extracted = extractTagsArrays({
|
|
145
|
+
xml: textChannel.topic,
|
|
146
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
147
|
+
});
|
|
148
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
149
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
150
|
+
return { projectDirectory, channelAppId };
|
|
151
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test, expect } from 'vitest';
|
|
2
2
|
import { Lexer } from 'marked';
|
|
3
|
-
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './
|
|
3
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js';
|
|
4
4
|
test('escapes single backticks in code blocks', () => {
|
|
5
5
|
const input = '```js\nconst x = `hello`\n```';
|
|
6
6
|
const result = escapeBackticksInCodeBlocks(input);
|
package/dist/fork.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
2
|
+
import { getDatabase } from './database.js';
|
|
3
|
+
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
4
|
+
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from './discord-utils.js';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
const sessionLogger = createLogger('SESSION');
|
|
7
|
+
const forkLogger = createLogger('FORK');
|
|
8
|
+
export async function handleForkCommand(interaction) {
|
|
9
|
+
const channel = interaction.channel;
|
|
10
|
+
if (!channel) {
|
|
11
|
+
await interaction.reply({
|
|
12
|
+
content: 'This command can only be used in a channel',
|
|
13
|
+
ephemeral: true,
|
|
14
|
+
});
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const isThread = [
|
|
18
|
+
ChannelType.PublicThread,
|
|
19
|
+
ChannelType.PrivateThread,
|
|
20
|
+
ChannelType.AnnouncementThread,
|
|
21
|
+
].includes(channel.type);
|
|
22
|
+
if (!isThread) {
|
|
23
|
+
await interaction.reply({
|
|
24
|
+
content: 'This command can only be used in a thread with an active session',
|
|
25
|
+
ephemeral: true,
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const textChannel = await resolveTextChannel(channel);
|
|
30
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
31
|
+
if (!directory) {
|
|
32
|
+
await interaction.reply({
|
|
33
|
+
content: 'Could not determine project directory for this channel',
|
|
34
|
+
ephemeral: true,
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const row = getDatabase()
|
|
39
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
40
|
+
.get(channel.id);
|
|
41
|
+
if (!row?.session_id) {
|
|
42
|
+
await interaction.reply({
|
|
43
|
+
content: 'No active session in this thread',
|
|
44
|
+
ephemeral: true,
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Defer reply before API calls to avoid 3-second timeout
|
|
49
|
+
await interaction.deferReply({ ephemeral: true });
|
|
50
|
+
const sessionId = row.session_id;
|
|
51
|
+
try {
|
|
52
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
53
|
+
const messagesResponse = await getClient().session.messages({
|
|
54
|
+
path: { id: sessionId },
|
|
55
|
+
});
|
|
56
|
+
if (!messagesResponse.data) {
|
|
57
|
+
await interaction.editReply({
|
|
58
|
+
content: 'Failed to fetch session messages',
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user');
|
|
63
|
+
if (userMessages.length === 0) {
|
|
64
|
+
await interaction.editReply({
|
|
65
|
+
content: 'No user messages found in this session',
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const recentMessages = userMessages.slice(-25);
|
|
70
|
+
const options = recentMessages.map((m, index) => {
|
|
71
|
+
const textPart = m.parts.find((p) => p.type === 'text');
|
|
72
|
+
const preview = textPart?.text?.slice(0, 80) || '(no text)';
|
|
73
|
+
const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`;
|
|
74
|
+
return {
|
|
75
|
+
label: label.slice(0, 100),
|
|
76
|
+
value: m.info.id,
|
|
77
|
+
description: new Date(m.info.time.created).toLocaleString().slice(0, 50),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
const encodedDir = Buffer.from(directory).toString('base64');
|
|
81
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
82
|
+
.setCustomId(`fork_select:${sessionId}:${encodedDir}`)
|
|
83
|
+
.setPlaceholder('Select a message to fork from')
|
|
84
|
+
.addOptions(options);
|
|
85
|
+
const actionRow = new ActionRowBuilder()
|
|
86
|
+
.addComponents(selectMenu);
|
|
87
|
+
await interaction.editReply({
|
|
88
|
+
content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
|
|
89
|
+
components: [actionRow],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
forkLogger.error('Error loading messages:', error);
|
|
94
|
+
await interaction.editReply({
|
|
95
|
+
content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function handleForkSelectMenu(interaction) {
|
|
100
|
+
const customId = interaction.customId;
|
|
101
|
+
if (!customId.startsWith('fork_select:')) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const [, sessionId, encodedDir] = customId.split(':');
|
|
105
|
+
if (!sessionId || !encodedDir) {
|
|
106
|
+
await interaction.reply({
|
|
107
|
+
content: 'Invalid selection data',
|
|
108
|
+
ephemeral: true,
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const directory = Buffer.from(encodedDir, 'base64').toString('utf-8');
|
|
113
|
+
const selectedMessageId = interaction.values[0];
|
|
114
|
+
if (!selectedMessageId) {
|
|
115
|
+
await interaction.reply({
|
|
116
|
+
content: 'No message selected',
|
|
117
|
+
ephemeral: true,
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await interaction.deferReply({ ephemeral: false });
|
|
122
|
+
try {
|
|
123
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
124
|
+
const forkResponse = await getClient().session.fork({
|
|
125
|
+
path: { id: sessionId },
|
|
126
|
+
body: { messageID: selectedMessageId },
|
|
127
|
+
});
|
|
128
|
+
if (!forkResponse.data) {
|
|
129
|
+
await interaction.editReply('Failed to fork session');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const forkedSession = forkResponse.data;
|
|
133
|
+
const parentChannel = interaction.channel;
|
|
134
|
+
if (!parentChannel || ![
|
|
135
|
+
ChannelType.PublicThread,
|
|
136
|
+
ChannelType.PrivateThread,
|
|
137
|
+
ChannelType.AnnouncementThread,
|
|
138
|
+
].includes(parentChannel.type)) {
|
|
139
|
+
await interaction.editReply('Could not access parent channel');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const textChannel = await resolveTextChannel(parentChannel);
|
|
143
|
+
if (!textChannel) {
|
|
144
|
+
await interaction.editReply('Could not resolve parent text channel');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const thread = await textChannel.threads.create({
|
|
148
|
+
name: `Fork: ${forkedSession.title}`.slice(0, 100),
|
|
149
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
150
|
+
reason: `Forked from session ${sessionId}`,
|
|
151
|
+
});
|
|
152
|
+
getDatabase()
|
|
153
|
+
.prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
|
|
154
|
+
.run(thread.id, forkedSession.id);
|
|
155
|
+
sessionLogger.log(`Created forked session ${forkedSession.id} in thread ${thread.id}`);
|
|
156
|
+
await sendThreadMessage(thread, `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\`\n\nYou can now continue the conversation from this point.`);
|
|
157
|
+
await interaction.editReply(`Session forked! Continue in ${thread.toString()}`);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
forkLogger.error('Error forking session:', error);
|
|
161
|
+
await interaction.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
162
|
+
}
|
|
163
|
+
}
|