kimaki 0.4.20 → 0.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -87,7 +87,7 @@ async function registerCommands(token, appId) {
87
87
  })
88
88
  .toJSON(),
89
89
  new SlashCommandBuilder()
90
- .setName('add-new-project')
90
+ .setName('create-new-project')
91
91
  .setDescription('Create a new project folder, initialize git, and start a session')
92
92
  .addStringOption((option) => {
93
93
  option
@@ -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';
@@ -43,6 +44,32 @@ The user cannot see bash tool outputs. If there is important information in bash
43
44
 
44
45
  Your current OpenCode session ID is: ${sessionId}
45
46
 
47
+ ## permissions
48
+
49
+ Only users with these Discord permissions can send messages to the bot:
50
+ - Server Owner
51
+ - Administrator permission
52
+ - Manage Server permission
53
+ - "Kimaki" role (case-insensitive)
54
+
55
+ ## changing the model
56
+
57
+ To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
58
+
59
+ \`\`\`json
60
+ {
61
+ "model": "anthropic/claude-sonnet-4-20250514"
62
+ }
63
+ \`\`\`
64
+
65
+ Examples:
66
+ - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
67
+ - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
68
+ - \`"openai/gpt-4o"\` - GPT-4o
69
+ - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
70
+
71
+ Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
72
+
46
73
  ## uploading files to discord
47
74
 
48
75
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
@@ -538,6 +565,7 @@ async function getOpenPort() {
538
565
  */
539
566
  async function sendThreadMessage(thread, content) {
540
567
  const MAX_LENGTH = 2000;
568
+ content = formatMarkdownTables(content);
541
569
  content = escapeBackticksInCodeBlocks(content);
542
570
  const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
543
571
  if (chunks.length > 1) {
@@ -585,7 +613,6 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
585
613
  if (!audioAttachment)
586
614
  return null;
587
615
  voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`);
588
- await message.react('⏳');
589
616
  await sendThreadMessage(thread, '🎤 Transcribing voice message...');
590
617
  const audioResponse = await fetch(audioAttachment.url);
591
618
  const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
@@ -793,7 +820,7 @@ function escapeInlineCode(text) {
793
820
  .replace(/(?<!\\)`(?!`)/g, '\\`') // Single backticks (not already escaped or part of double/triple)
794
821
  .replace(/\|\|/g, '\\|\\|'); // Double pipes (spoiler syntax)
795
822
  }
796
- function resolveTextChannel(channel) {
823
+ async function resolveTextChannel(channel) {
797
824
  if (!channel) {
798
825
  return null;
799
826
  }
@@ -803,9 +830,12 @@ function resolveTextChannel(channel) {
803
830
  if (channel.type === ChannelType.PublicThread ||
804
831
  channel.type === ChannelType.PrivateThread ||
805
832
  channel.type === ChannelType.AnnouncementThread) {
806
- const parent = channel.parent;
807
- if (parent?.type === ChannelType.GuildText) {
808
- 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
+ }
809
839
  }
810
840
  }
811
841
  return null;
@@ -940,32 +970,55 @@ function getToolSummaryText(part) {
940
970
  if (part.type !== 'tool')
941
971
  return '';
942
972
  if (part.tool === 'edit') {
973
+ const filePath = part.state.input?.filePath || '';
943
974
  const newString = part.state.input?.newString || '';
944
975
  const oldString = part.state.input?.oldString || '';
945
976
  const added = newString.split('\n').length;
946
977
  const removed = oldString.split('\n').length;
947
- return `(+${added}-${removed})`;
978
+ const fileName = filePath.split('/').pop() || '';
979
+ return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`;
948
980
  }
949
981
  if (part.tool === 'write') {
982
+ const filePath = part.state.input?.filePath || '';
950
983
  const content = part.state.input?.content || '';
951
984
  const lines = content.split('\n').length;
952
- return `(${lines} line${lines === 1 ? '' : 's'})`;
985
+ const fileName = filePath.split('/').pop() || '';
986
+ return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
953
987
  }
954
988
  if (part.tool === 'webfetch') {
955
989
  const url = part.state.input?.url || '';
956
990
  const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
957
- return urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
991
+ return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
992
+ }
993
+ if (part.tool === 'read') {
994
+ const filePath = part.state.input?.filePath || '';
995
+ const fileName = filePath.split('/').pop() || '';
996
+ return fileName ? `*${fileName}*` : '';
997
+ }
998
+ if (part.tool === 'list') {
999
+ const path = part.state.input?.path || '';
1000
+ const dirName = path.split('/').pop() || path;
1001
+ return dirName ? `*${dirName}*` : '';
1002
+ }
1003
+ if (part.tool === 'glob') {
1004
+ const pattern = part.state.input?.pattern || '';
1005
+ return pattern ? `*${pattern}*` : '';
958
1006
  }
959
- if (part.tool === 'bash' ||
960
- part.tool === 'read' ||
961
- part.tool === 'list' ||
962
- part.tool === 'glob' ||
963
- part.tool === 'grep' ||
964
- part.tool === 'task' ||
965
- part.tool === 'todoread' ||
966
- part.tool === 'todowrite') {
1007
+ if (part.tool === 'grep') {
1008
+ const pattern = part.state.input?.pattern || '';
1009
+ return pattern ? `*${pattern}*` : '';
1010
+ }
1011
+ if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
967
1012
  return '';
968
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
+ }
969
1022
  if (!part.state.input)
970
1023
  return '';
971
1024
  const inputFields = Object.entries(part.state.input)
@@ -985,20 +1038,13 @@ function formatTodoList(part) {
985
1038
  if (part.type !== 'tool' || part.tool !== 'todowrite')
986
1039
  return '';
987
1040
  const todos = part.state.input?.todos || [];
988
- 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)
989
1046
  return '';
990
- return todos
991
- .map((todo, i) => {
992
- const num = `${i + 1}.`;
993
- if (todo.status === 'in_progress') {
994
- return `${num} **${todo.content}**`;
995
- }
996
- if (todo.status === 'completed' || todo.status === 'cancelled') {
997
- return `${num} ~~${todo.content}~~`;
998
- }
999
- return `${num} ${todo.content}`;
1000
- })
1001
- .join('\n');
1047
+ return `${activeIndex + 1}. **${activeTodo.content}**`;
1002
1048
  }
1003
1049
  function formatPart(part) {
1004
1050
  if (part.type === 'text') {
@@ -1036,19 +1082,18 @@ function formatPart(part) {
1036
1082
  }
1037
1083
  else if (part.tool === 'bash') {
1038
1084
  const command = part.state.input?.command || '';
1085
+ const description = part.state.input?.description || '';
1039
1086
  const isSingleLine = !command.includes('\n');
1040
- const hasBackticks = command.includes('`');
1041
- if (isSingleLine && command.length <= 120 && !hasBackticks) {
1087
+ const hasUnderscores = command.includes('_');
1088
+ if (isSingleLine && !hasUnderscores && command.length <= 50) {
1042
1089
  toolTitle = `_${command}_`;
1043
1090
  }
1044
- else {
1045
- toolTitle = stateTitle ? `_${stateTitle}_` : '';
1091
+ else if (description) {
1092
+ toolTitle = `_${description}_`;
1093
+ }
1094
+ else if (stateTitle) {
1095
+ toolTitle = `_${stateTitle}_`;
1046
1096
  }
1047
- }
1048
- else if (part.tool === 'edit' || part.tool === 'write') {
1049
- const filePath = part.state.input?.filePath || '';
1050
- const fileName = filePath.split('/').pop() || filePath;
1051
- toolTitle = fileName ? `_${fileName}_` : '';
1052
1097
  }
1053
1098
  else if (stateTitle) {
1054
1099
  toolTitle = `_${stateTitle}_`;
@@ -1160,6 +1205,41 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1160
1205
  let usedModel;
1161
1206
  let usedProviderID;
1162
1207
  let tokensUsedInSession = 0;
1208
+ let lastDisplayedContextPercentage = 0;
1209
+ let modelContextLimit;
1210
+ let typingInterval = null;
1211
+ function startTyping() {
1212
+ if (abortController.signal.aborted) {
1213
+ discordLogger.log(`Not starting typing, already aborted`);
1214
+ return () => { };
1215
+ }
1216
+ if (typingInterval) {
1217
+ clearInterval(typingInterval);
1218
+ typingInterval = null;
1219
+ }
1220
+ thread.sendTyping().catch((e) => {
1221
+ discordLogger.log(`Failed to send initial typing: ${e}`);
1222
+ });
1223
+ typingInterval = setInterval(() => {
1224
+ thread.sendTyping().catch((e) => {
1225
+ discordLogger.log(`Failed to send periodic typing: ${e}`);
1226
+ });
1227
+ }, 8000);
1228
+ if (!abortController.signal.aborted) {
1229
+ abortController.signal.addEventListener('abort', () => {
1230
+ if (typingInterval) {
1231
+ clearInterval(typingInterval);
1232
+ typingInterval = null;
1233
+ }
1234
+ }, { once: true });
1235
+ }
1236
+ return () => {
1237
+ if (typingInterval) {
1238
+ clearInterval(typingInterval);
1239
+ typingInterval = null;
1240
+ }
1241
+ };
1242
+ }
1163
1243
  const sendPartMessage = async (part) => {
1164
1244
  const content = formatPart(part) + '\n\n';
1165
1245
  if (!content.trim() || content.length === 0) {
@@ -1183,48 +1263,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1183
1263
  }
1184
1264
  };
1185
1265
  const eventHandler = async () => {
1186
- // Local typing function for this session
1187
- // Outer-scoped interval for typing notifications. Only one at a time.
1188
- let typingInterval = null;
1189
- function startTyping(thread) {
1190
- if (abortController.signal.aborted) {
1191
- discordLogger.log(`Not starting typing, already aborted`);
1192
- return () => { };
1193
- }
1194
- // Clear any previous typing interval
1195
- if (typingInterval) {
1196
- clearInterval(typingInterval);
1197
- typingInterval = null;
1198
- }
1199
- // Send initial typing
1200
- thread.sendTyping().catch((e) => {
1201
- discordLogger.log(`Failed to send initial typing: ${e}`);
1202
- });
1203
- // Set up interval to send typing every 8 seconds
1204
- typingInterval = setInterval(() => {
1205
- thread.sendTyping().catch((e) => {
1206
- discordLogger.log(`Failed to send periodic typing: ${e}`);
1207
- });
1208
- }, 8000);
1209
- // Only add listener if not already aborted
1210
- if (!abortController.signal.aborted) {
1211
- abortController.signal.addEventListener('abort', () => {
1212
- if (typingInterval) {
1213
- clearInterval(typingInterval);
1214
- typingInterval = null;
1215
- }
1216
- }, {
1217
- once: true,
1218
- });
1219
- }
1220
- // Return stop function
1221
- return () => {
1222
- if (typingInterval) {
1223
- clearInterval(typingInterval);
1224
- typingInterval = null;
1225
- }
1226
- };
1227
- }
1228
1266
  try {
1229
1267
  let assistantMessageId;
1230
1268
  for await (const event of events) {
@@ -1242,6 +1280,29 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1242
1280
  assistantMessageId = msg.id;
1243
1281
  usedModel = msg.modelID;
1244
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
+ }
1245
1306
  }
1246
1307
  }
1247
1308
  else if (event.type === 'message.part.updated') {
@@ -1262,7 +1323,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1262
1323
  }
1263
1324
  // Start typing on step-start
1264
1325
  if (part.type === 'step-start') {
1265
- stopTyping = startTyping(thread);
1326
+ stopTyping = startTyping();
1266
1327
  }
1267
1328
  // Send tool parts immediately when they start running
1268
1329
  if (part.type === 'tool' && part.state.status === 'running') {
@@ -1285,7 +1346,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1285
1346
  setTimeout(() => {
1286
1347
  if (abortController.signal.aborted)
1287
1348
  return;
1288
- stopTyping = startTyping(thread);
1349
+ stopTyping = startTyping();
1289
1350
  }, 300);
1290
1351
  }
1291
1352
  }
@@ -1405,14 +1466,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
1405
1466
  sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
1406
1467
  return;
1407
1468
  }
1408
- if (originalMessage) {
1409
- try {
1410
- await originalMessage.react('⏳');
1411
- }
1412
- catch (e) {
1413
- discordLogger.log(`Could not add processing reaction:`, e);
1414
- }
1415
- }
1469
+ stopTyping = startTyping();
1416
1470
  let response;
1417
1471
  if (parsedCommand?.isCommand) {
1418
1472
  sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
@@ -1579,11 +1633,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1579
1633
  return;
1580
1634
  }
1581
1635
  }
1582
- // Check if user is authoritative (server owner or has admin permissions)
1636
+ // Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
1583
1637
  if (message.guild && message.member) {
1584
1638
  const isOwner = message.member.id === message.guild.ownerId;
1585
1639
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
1586
- if (!isOwner && !isAdmin) {
1640
+ const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
1641
+ const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
1642
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
1587
1643
  return;
1588
1644
  }
1589
1645
  }
@@ -1749,9 +1805,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1749
1805
  const focusedValue = interaction.options.getFocused();
1750
1806
  // Get the channel's project directory from its topic
1751
1807
  let projectDirectory;
1752
- if (interaction.channel &&
1753
- interaction.channel.type === ChannelType.GuildText) {
1754
- const textChannel = resolveTextChannel(interaction.channel);
1808
+ if (interaction.channel) {
1809
+ const textChannel = await resolveTextChannel(interaction.channel);
1755
1810
  if (textChannel) {
1756
1811
  const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
1757
1812
  if (channelAppId && channelAppId !== currentAppId) {
@@ -1814,9 +1869,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1814
1869
  const currentQuery = (parts[parts.length - 1] || '').trim();
1815
1870
  // Get the channel's project directory from its topic
1816
1871
  let projectDirectory;
1817
- if (interaction.channel &&
1818
- interaction.channel.type === ChannelType.GuildText) {
1819
- const textChannel = resolveTextChannel(interaction.channel);
1872
+ if (interaction.channel) {
1873
+ const textChannel = await resolveTextChannel(interaction.channel);
1820
1874
  if (textChannel) {
1821
1875
  const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
1822
1876
  if (channelAppId && channelAppId !== currentAppId) {
@@ -2078,7 +2132,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2078
2132
  if (partsToRender.length > 0) {
2079
2133
  const combinedContent = partsToRender
2080
2134
  .map((p) => p.content)
2081
- .join('\n\n');
2135
+ .join('\n');
2082
2136
  const discordMessage = await sendThreadMessage(thread, combinedContent);
2083
2137
  const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
2084
2138
  const transaction = getDatabase().transaction((parts) => {
@@ -2143,7 +2197,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2143
2197
  await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
2144
2198
  }
2145
2199
  }
2146
- else if (command.commandName === 'add-new-project') {
2200
+ else if (command.commandName === 'create-new-project') {
2147
2201
  await command.deferReply({ ephemeral: false });
2148
2202
  const projectName = command.options.getString('name', true);
2149
2203
  const guild = command.guild;
@@ -2339,7 +2393,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2339
2393
  });
2340
2394
  return;
2341
2395
  }
2342
- const textChannel = resolveTextChannel(channel);
2396
+ const textChannel = await resolveTextChannel(channel);
2343
2397
  const { projectDirectory: directory } = getKimakiMetadata(textChannel);
2344
2398
  if (!directory) {
2345
2399
  await command.reply({
@@ -2401,7 +2455,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2401
2455
  });
2402
2456
  return;
2403
2457
  }
2404
- const textChannel = resolveTextChannel(channel);
2458
+ const textChannel = await resolveTextChannel(channel);
2405
2459
  const { projectDirectory: directory } = getKimakiMetadata(textChannel);
2406
2460
  if (!directory) {
2407
2461
  await command.reply({
@@ -2496,12 +2550,13 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2496
2550
  const member = newState.member || oldState.member;
2497
2551
  if (!member)
2498
2552
  return;
2499
- // Check if user is admin or server owner
2553
+ // Check if user is admin, server owner, can manage server, or has Kimaki role
2500
2554
  const guild = newState.guild || oldState.guild;
2501
2555
  const isOwner = member.id === guild.ownerId;
2502
2556
  const isAdmin = member.permissions.has(PermissionsBitField.Flags.Administrator);
2503
- if (!isOwner && !isAdmin) {
2504
- // Not an admin user, ignore
2557
+ const canManageServer = member.permissions.has(PermissionsBitField.Flags.ManageGuild);
2558
+ const hasKimakiRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
2559
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
2505
2560
  return;
2506
2561
  }
2507
2562
  // Handle admin leaving voice channel
@@ -2520,7 +2575,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2520
2575
  if (m.id === member.id || m.user.bot)
2521
2576
  return false;
2522
2577
  return (m.id === guild.ownerId ||
2523
- m.permissions.has(PermissionsBitField.Flags.Administrator));
2578
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
2579
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
2580
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
2524
2581
  });
2525
2582
  if (!hasOtherAdmins) {
2526
2583
  voiceLogger.log(`No other admins in channel, bot leaving voice channel in guild: ${guild.name}`);
@@ -2550,7 +2607,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
2550
2607
  if (m.id === member.id || m.user.bot)
2551
2608
  return false;
2552
2609
  return (m.id === guild.ownerId ||
2553
- m.permissions.has(PermissionsBitField.Flags.Administrator));
2610
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
2611
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
2612
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki'));
2554
2613
  });
2555
2614
  if (!hasOtherAdmins) {
2556
2615
  voiceLogger.log(`Following admin to new channel: ${newState.channel?.name}`);
@@ -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
+ }