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.
Files changed (43) hide show
  1. package/dist/channel-management.js +92 -0
  2. package/dist/cli.js +10 -2
  3. package/dist/database.js +130 -0
  4. package/dist/discord-bot.js +381 -0
  5. package/dist/discord-utils.js +151 -0
  6. package/dist/discordBot.js +60 -31
  7. package/dist/escape-backticks.test.js +1 -1
  8. package/dist/fork.js +163 -0
  9. package/dist/format-tables.js +93 -0
  10. package/dist/format-tables.test.js +418 -0
  11. package/dist/interaction-handler.js +750 -0
  12. package/dist/markdown.js +3 -3
  13. package/dist/message-formatting.js +188 -0
  14. package/dist/model-command.js +293 -0
  15. package/dist/opencode.js +135 -0
  16. package/dist/session-handler.js +467 -0
  17. package/dist/system-message.js +92 -0
  18. package/dist/tools.js +3 -5
  19. package/dist/utils.js +31 -0
  20. package/dist/voice-handler.js +528 -0
  21. package/dist/voice.js +257 -35
  22. package/package.json +3 -2
  23. package/src/channel-management.ts +145 -0
  24. package/src/cli.ts +10 -2
  25. package/src/database.ts +155 -0
  26. package/src/discord-bot.ts +506 -0
  27. package/src/discord-utils.ts +208 -0
  28. package/src/escape-backticks.test.ts +1 -1
  29. package/src/fork.ts +224 -0
  30. package/src/format-tables.test.ts +440 -0
  31. package/src/format-tables.ts +106 -0
  32. package/src/interaction-handler.ts +1000 -0
  33. package/src/markdown.ts +3 -3
  34. package/src/message-formatting.ts +227 -0
  35. package/src/model-command.ts +380 -0
  36. package/src/opencode.ts +180 -0
  37. package/src/session-handler.ts +601 -0
  38. package/src/system-message.ts +92 -0
  39. package/src/tools.ts +3 -5
  40. package/src/utils.ts +37 -0
  41. package/src/voice-handler.ts +745 -0
  42. package/src/voice.ts +354 -36
  43. 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
+ }
@@ -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 parent = channel.parent;
832
- if (parent?.type === ChannelType.GuildText) {
833
- return parent;
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 === 'task' || part.tool === 'todoread' || part.tool === 'todowrite') {
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
- if (todos.length === 0)
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 todos
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 hasBackticks = command.includes('`');
1082
- if (isSingleLine && !hasBackticks && command.length <= 50) {
1083
- toolTitle = `\`${command}\``;
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.type === ChannelType.GuildText) {
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.type === ChannelType.GuildText) {
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\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 === 'add-new-project') {
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 './discordBot.js';
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
+ }