kimaki 0.4.21 → 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 +10 -2
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/discordBot.js +60 -31
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/format-tables.js +93 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/markdown.js +3 -3
- 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 +3 -5
- package/dist/utils.js +31 -0
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -2
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +10 -2
- 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/format-tables.test.ts +440 -0
- package/src/format-tables.ts +106 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/markdown.ts +3 -3
- 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 +3 -5
- package/src/utils.ts +37 -0
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3643
|
@@ -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
|
+
}
|
package/dist/discordBot.js
CHANGED
|
@@ -16,6 +16,7 @@ import * as prism from 'prism-media';
|
|
|
16
16
|
import dedent from 'string-dedent';
|
|
17
17
|
import { transcribeAudio } from './voice.js';
|
|
18
18
|
import { extractTagsArrays, extractNonXmlContent } from './xml.js';
|
|
19
|
+
import { formatMarkdownTables } from './format-tables.js';
|
|
19
20
|
import prettyMilliseconds from 'pretty-ms';
|
|
20
21
|
import { createLogger } from './logger.js';
|
|
21
22
|
import { isAbortError } from './utils.js';
|
|
@@ -564,6 +565,7 @@ async function getOpenPort() {
|
|
|
564
565
|
*/
|
|
565
566
|
async function sendThreadMessage(thread, content) {
|
|
566
567
|
const MAX_LENGTH = 2000;
|
|
568
|
+
content = formatMarkdownTables(content);
|
|
567
569
|
content = escapeBackticksInCodeBlocks(content);
|
|
568
570
|
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
|
|
569
571
|
if (chunks.length > 1) {
|
|
@@ -818,7 +820,7 @@ function escapeInlineCode(text) {
|
|
|
818
820
|
.replace(/(?<!\\)`(?!`)/g, '\\`') // Single backticks (not already escaped or part of double/triple)
|
|
819
821
|
.replace(/\|\|/g, '\\|\\|'); // Double pipes (spoiler syntax)
|
|
820
822
|
}
|
|
821
|
-
function resolveTextChannel(channel) {
|
|
823
|
+
async function resolveTextChannel(channel) {
|
|
822
824
|
if (!channel) {
|
|
823
825
|
return null;
|
|
824
826
|
}
|
|
@@ -828,9 +830,12 @@ function resolveTextChannel(channel) {
|
|
|
828
830
|
if (channel.type === ChannelType.PublicThread ||
|
|
829
831
|
channel.type === ChannelType.PrivateThread ||
|
|
830
832
|
channel.type === ChannelType.AnnouncementThread) {
|
|
831
|
-
const
|
|
832
|
-
if (
|
|
833
|
-
|
|
833
|
+
const parentId = channel.parentId;
|
|
834
|
+
if (parentId) {
|
|
835
|
+
const parent = await channel.guild.channels.fetch(parentId);
|
|
836
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
837
|
+
return parent;
|
|
838
|
+
}
|
|
834
839
|
}
|
|
835
840
|
}
|
|
836
841
|
return null;
|
|
@@ -1003,9 +1008,17 @@ function getToolSummaryText(part) {
|
|
|
1003
1008
|
const pattern = part.state.input?.pattern || '';
|
|
1004
1009
|
return pattern ? `*${pattern}*` : '';
|
|
1005
1010
|
}
|
|
1006
|
-
if (part.tool === 'bash' || part.tool === '
|
|
1011
|
+
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
1007
1012
|
return '';
|
|
1008
1013
|
}
|
|
1014
|
+
if (part.tool === 'task') {
|
|
1015
|
+
const description = part.state.input?.description || '';
|
|
1016
|
+
return description ? `_${description}_` : '';
|
|
1017
|
+
}
|
|
1018
|
+
if (part.tool === 'skill') {
|
|
1019
|
+
const name = part.state.input?.name || '';
|
|
1020
|
+
return name ? `_${name}_` : '';
|
|
1021
|
+
}
|
|
1009
1022
|
if (!part.state.input)
|
|
1010
1023
|
return '';
|
|
1011
1024
|
const inputFields = Object.entries(part.state.input)
|
|
@@ -1025,20 +1038,13 @@ function formatTodoList(part) {
|
|
|
1025
1038
|
if (part.type !== 'tool' || part.tool !== 'todowrite')
|
|
1026
1039
|
return '';
|
|
1027
1040
|
const todos = part.state.input?.todos || [];
|
|
1028
|
-
|
|
1041
|
+
const activeIndex = todos.findIndex((todo) => {
|
|
1042
|
+
return todo.status === 'in_progress';
|
|
1043
|
+
});
|
|
1044
|
+
const activeTodo = todos[activeIndex];
|
|
1045
|
+
if (activeIndex === -1 || !activeTodo)
|
|
1029
1046
|
return '';
|
|
1030
|
-
return
|
|
1031
|
-
.map((todo, i) => {
|
|
1032
|
-
const num = `${i + 1}.`;
|
|
1033
|
-
if (todo.status === 'in_progress') {
|
|
1034
|
-
return `${num} **${todo.content}**`;
|
|
1035
|
-
}
|
|
1036
|
-
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
1037
|
-
return `${num} ~~${todo.content}~~`;
|
|
1038
|
-
}
|
|
1039
|
-
return `${num} ${todo.content}`;
|
|
1040
|
-
})
|
|
1041
|
-
.join('\n');
|
|
1047
|
+
return `${activeIndex + 1}. **${activeTodo.content}**`;
|
|
1042
1048
|
}
|
|
1043
1049
|
function formatPart(part) {
|
|
1044
1050
|
if (part.type === 'text') {
|
|
@@ -1078,9 +1084,9 @@ function formatPart(part) {
|
|
|
1078
1084
|
const command = part.state.input?.command || '';
|
|
1079
1085
|
const description = part.state.input?.description || '';
|
|
1080
1086
|
const isSingleLine = !command.includes('\n');
|
|
1081
|
-
const
|
|
1082
|
-
if (isSingleLine && !
|
|
1083
|
-
toolTitle =
|
|
1087
|
+
const hasUnderscores = command.includes('_');
|
|
1088
|
+
if (isSingleLine && !hasUnderscores && command.length <= 50) {
|
|
1089
|
+
toolTitle = `_${command}_`;
|
|
1084
1090
|
}
|
|
1085
1091
|
else if (description) {
|
|
1086
1092
|
toolTitle = `_${description}_`;
|
|
@@ -1199,6 +1205,8 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1199
1205
|
let usedModel;
|
|
1200
1206
|
let usedProviderID;
|
|
1201
1207
|
let tokensUsedInSession = 0;
|
|
1208
|
+
let lastDisplayedContextPercentage = 0;
|
|
1209
|
+
let modelContextLimit;
|
|
1202
1210
|
let typingInterval = null;
|
|
1203
1211
|
function startTyping() {
|
|
1204
1212
|
if (abortController.signal.aborted) {
|
|
@@ -1272,6 +1280,29 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1272
1280
|
assistantMessageId = msg.id;
|
|
1273
1281
|
usedModel = msg.modelID;
|
|
1274
1282
|
usedProviderID = msg.providerID;
|
|
1283
|
+
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
1284
|
+
if (!modelContextLimit) {
|
|
1285
|
+
try {
|
|
1286
|
+
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
1287
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
1288
|
+
const model = provider?.models?.[usedModel];
|
|
1289
|
+
if (model?.limit?.context) {
|
|
1290
|
+
modelContextLimit = model.limit.context;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
catch (e) {
|
|
1294
|
+
sessionLogger.error('Failed to fetch provider info for context limit:', e);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (modelContextLimit) {
|
|
1298
|
+
const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
|
|
1299
|
+
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
|
|
1300
|
+
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
1301
|
+
lastDisplayedContextPercentage = thresholdCrossed;
|
|
1302
|
+
await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1275
1306
|
}
|
|
1276
1307
|
}
|
|
1277
1308
|
else if (event.type === 'message.part.updated') {
|
|
@@ -1774,9 +1805,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1774
1805
|
const focusedValue = interaction.options.getFocused();
|
|
1775
1806
|
// Get the channel's project directory from its topic
|
|
1776
1807
|
let projectDirectory;
|
|
1777
|
-
if (interaction.channel
|
|
1778
|
-
interaction.channel
|
|
1779
|
-
const textChannel = resolveTextChannel(interaction.channel);
|
|
1808
|
+
if (interaction.channel) {
|
|
1809
|
+
const textChannel = await resolveTextChannel(interaction.channel);
|
|
1780
1810
|
if (textChannel) {
|
|
1781
1811
|
const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
|
|
1782
1812
|
if (channelAppId && channelAppId !== currentAppId) {
|
|
@@ -1839,9 +1869,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1839
1869
|
const currentQuery = (parts[parts.length - 1] || '').trim();
|
|
1840
1870
|
// Get the channel's project directory from its topic
|
|
1841
1871
|
let projectDirectory;
|
|
1842
|
-
if (interaction.channel
|
|
1843
|
-
interaction.channel
|
|
1844
|
-
const textChannel = resolveTextChannel(interaction.channel);
|
|
1872
|
+
if (interaction.channel) {
|
|
1873
|
+
const textChannel = await resolveTextChannel(interaction.channel);
|
|
1845
1874
|
if (textChannel) {
|
|
1846
1875
|
const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
|
|
1847
1876
|
if (channelAppId && channelAppId !== currentAppId) {
|
|
@@ -2103,7 +2132,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2103
2132
|
if (partsToRender.length > 0) {
|
|
2104
2133
|
const combinedContent = partsToRender
|
|
2105
2134
|
.map((p) => p.content)
|
|
2106
|
-
.join('\n
|
|
2135
|
+
.join('\n');
|
|
2107
2136
|
const discordMessage = await sendThreadMessage(thread, combinedContent);
|
|
2108
2137
|
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
2109
2138
|
const transaction = getDatabase().transaction((parts) => {
|
|
@@ -2168,7 +2197,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2168
2197
|
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
2169
2198
|
}
|
|
2170
2199
|
}
|
|
2171
|
-
else if (command.commandName === '
|
|
2200
|
+
else if (command.commandName === 'create-new-project') {
|
|
2172
2201
|
await command.deferReply({ ephemeral: false });
|
|
2173
2202
|
const projectName = command.options.getString('name', true);
|
|
2174
2203
|
const guild = command.guild;
|
|
@@ -2364,7 +2393,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2364
2393
|
});
|
|
2365
2394
|
return;
|
|
2366
2395
|
}
|
|
2367
|
-
const textChannel = resolveTextChannel(channel);
|
|
2396
|
+
const textChannel = await resolveTextChannel(channel);
|
|
2368
2397
|
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2369
2398
|
if (!directory) {
|
|
2370
2399
|
await command.reply({
|
|
@@ -2426,7 +2455,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2426
2455
|
});
|
|
2427
2456
|
return;
|
|
2428
2457
|
}
|
|
2429
|
-
const textChannel = resolveTextChannel(channel);
|
|
2458
|
+
const textChannel = await resolveTextChannel(channel);
|
|
2430
2459
|
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2431
2460
|
if (!directory) {
|
|
2432
2461
|
await command.reply({
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Lexer } from 'marked';
|
|
2
|
+
export function formatMarkdownTables(markdown) {
|
|
3
|
+
const lexer = new Lexer();
|
|
4
|
+
const tokens = lexer.lex(markdown);
|
|
5
|
+
let result = '';
|
|
6
|
+
for (const token of tokens) {
|
|
7
|
+
if (token.type === 'table') {
|
|
8
|
+
result += formatTableToken(token);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
result += token.raw;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
function formatTableToken(table) {
|
|
17
|
+
const headers = table.header.map((cell) => {
|
|
18
|
+
return extractCellText(cell.tokens);
|
|
19
|
+
});
|
|
20
|
+
const rows = table.rows.map((row) => {
|
|
21
|
+
return row.map((cell) => {
|
|
22
|
+
return extractCellText(cell.tokens);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const columnWidths = calculateColumnWidths(headers, rows);
|
|
26
|
+
const lines = [];
|
|
27
|
+
lines.push(formatRow(headers, columnWidths));
|
|
28
|
+
lines.push(formatSeparator(columnWidths));
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
lines.push(formatRow(row, columnWidths));
|
|
31
|
+
}
|
|
32
|
+
return '```\n' + lines.join('\n') + '\n```\n';
|
|
33
|
+
}
|
|
34
|
+
function extractCellText(tokens) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
for (const token of tokens) {
|
|
37
|
+
parts.push(extractTokenText(token));
|
|
38
|
+
}
|
|
39
|
+
return parts.join('').trim();
|
|
40
|
+
}
|
|
41
|
+
function extractTokenText(token) {
|
|
42
|
+
switch (token.type) {
|
|
43
|
+
case 'text':
|
|
44
|
+
case 'codespan':
|
|
45
|
+
case 'escape':
|
|
46
|
+
return token.text;
|
|
47
|
+
case 'link':
|
|
48
|
+
return token.href;
|
|
49
|
+
case 'image':
|
|
50
|
+
return token.href;
|
|
51
|
+
case 'strong':
|
|
52
|
+
case 'em':
|
|
53
|
+
case 'del':
|
|
54
|
+
return token.tokens ? extractCellText(token.tokens) : token.text;
|
|
55
|
+
case 'br':
|
|
56
|
+
return ' ';
|
|
57
|
+
default: {
|
|
58
|
+
const tokenAny = token;
|
|
59
|
+
if (tokenAny.tokens && Array.isArray(tokenAny.tokens)) {
|
|
60
|
+
return extractCellText(tokenAny.tokens);
|
|
61
|
+
}
|
|
62
|
+
if (typeof tokenAny.text === 'string') {
|
|
63
|
+
return tokenAny.text;
|
|
64
|
+
}
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function calculateColumnWidths(headers, rows) {
|
|
70
|
+
const widths = headers.map((h) => {
|
|
71
|
+
return h.length;
|
|
72
|
+
});
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
for (let i = 0; i < row.length; i++) {
|
|
75
|
+
const cell = row[i] ?? '';
|
|
76
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return widths;
|
|
80
|
+
}
|
|
81
|
+
function formatRow(cells, widths) {
|
|
82
|
+
const paddedCells = cells.map((cell, i) => {
|
|
83
|
+
return cell.padEnd(widths[i] ?? 0);
|
|
84
|
+
});
|
|
85
|
+
return paddedCells.join(' ');
|
|
86
|
+
}
|
|
87
|
+
function formatSeparator(widths) {
|
|
88
|
+
return widths
|
|
89
|
+
.map((w) => {
|
|
90
|
+
return '-'.repeat(w);
|
|
91
|
+
})
|
|
92
|
+
.join(' ');
|
|
93
|
+
}
|