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/src/discordBot.ts CHANGED
@@ -46,6 +46,7 @@ import * as prism from 'prism-media'
46
46
  import dedent from 'string-dedent'
47
47
  import { transcribeAudio } from './voice.js'
48
48
  import { extractTagsArrays, extractNonXmlContent } from './xml.js'
49
+ import { formatMarkdownTables } from './format-tables.js'
49
50
  import prettyMilliseconds from 'pretty-ms'
50
51
  import type { Session } from '@google/genai'
51
52
  import { createLogger } from './logger.js'
@@ -61,7 +62,6 @@ type ParsedCommand = {
61
62
  } | {
62
63
  isCommand: false
63
64
  }
64
-
65
65
  function parseSlashCommand(text: string): ParsedCommand {
66
66
  const trimmed = text.trim()
67
67
  if (!trimmed.startsWith('/')) {
@@ -84,6 +84,32 @@ The user cannot see bash tool outputs. If there is important information in bash
84
84
 
85
85
  Your current OpenCode session ID is: ${sessionId}
86
86
 
87
+ ## permissions
88
+
89
+ Only users with these Discord permissions can send messages to the bot:
90
+ - Server Owner
91
+ - Administrator permission
92
+ - Manage Server permission
93
+ - "Kimaki" role (case-insensitive)
94
+
95
+ ## changing the model
96
+
97
+ To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
98
+
99
+ \`\`\`json
100
+ {
101
+ "model": "anthropic/claude-sonnet-4-20250514"
102
+ }
103
+ \`\`\`
104
+
105
+ Examples:
106
+ - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
107
+ - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
108
+ - \`"openai/gpt-4o"\` - GPT-4o
109
+ - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
110
+
111
+ Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
112
+
87
113
  ## uploading files to discord
88
114
 
89
115
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
@@ -731,6 +757,7 @@ async function sendThreadMessage(
731
757
  ): Promise<Message> {
732
758
  const MAX_LENGTH = 2000
733
759
 
760
+ content = formatMarkdownTables(content)
734
761
  content = escapeBackticksInCodeBlocks(content)
735
762
 
736
763
  const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
@@ -805,7 +832,6 @@ async function processVoiceAttachment({
805
832
  `Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
806
833
  )
807
834
 
808
- await message.react('⏳')
809
835
  await sendThreadMessage(thread, '🎤 Transcribing voice message...')
810
836
 
811
837
  const audioResponse = await fetch(audioAttachment.url)
@@ -1068,9 +1094,9 @@ function escapeInlineCode(text: string): string {
1068
1094
  .replace(/\|\|/g, '\\|\\|') // Double pipes (spoiler syntax)
1069
1095
  }
1070
1096
 
1071
- function resolveTextChannel(
1097
+ async function resolveTextChannel(
1072
1098
  channel: TextChannel | ThreadChannel | null | undefined,
1073
- ): TextChannel | null {
1099
+ ): Promise<TextChannel | null> {
1074
1100
  if (!channel) {
1075
1101
  return null
1076
1102
  }
@@ -1084,9 +1110,12 @@ function resolveTextChannel(
1084
1110
  channel.type === ChannelType.PrivateThread ||
1085
1111
  channel.type === ChannelType.AnnouncementThread
1086
1112
  ) {
1087
- const parent = channel.parent
1088
- if (parent?.type === ChannelType.GuildText) {
1089
- return parent as TextChannel
1113
+ const parentId = channel.parentId
1114
+ if (parentId) {
1115
+ const parent = await channel.guild.channels.fetch(parentId)
1116
+ if (parent?.type === ChannelType.GuildText) {
1117
+ return parent as TextChannel
1118
+ }
1090
1119
  }
1091
1120
  }
1092
1121
 
@@ -1259,38 +1288,65 @@ function getToolSummaryText(part: Part): string {
1259
1288
  if (part.type !== 'tool') return ''
1260
1289
 
1261
1290
  if (part.tool === 'edit') {
1291
+ const filePath = (part.state.input?.filePath as string) || ''
1262
1292
  const newString = (part.state.input?.newString as string) || ''
1263
1293
  const oldString = (part.state.input?.oldString as string) || ''
1264
1294
  const added = newString.split('\n').length
1265
1295
  const removed = oldString.split('\n').length
1266
- return `(+${added}-${removed})`
1296
+ const fileName = filePath.split('/').pop() || ''
1297
+ return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
1267
1298
  }
1268
1299
 
1269
1300
  if (part.tool === 'write') {
1301
+ const filePath = (part.state.input?.filePath as string) || ''
1270
1302
  const content = (part.state.input?.content as string) || ''
1271
1303
  const lines = content.split('\n').length
1272
- return `(${lines} line${lines === 1 ? '' : 's'})`
1304
+ const fileName = filePath.split('/').pop() || ''
1305
+ return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
1273
1306
  }
1274
1307
 
1275
1308
  if (part.tool === 'webfetch') {
1276
1309
  const url = (part.state.input?.url as string) || ''
1277
1310
  const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
1278
- return urlWithoutProtocol ? `(${urlWithoutProtocol})` : ''
1311
+ return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
1279
1312
  }
1280
1313
 
1281
- if (
1282
- part.tool === 'bash' ||
1283
- part.tool === 'read' ||
1284
- part.tool === 'list' ||
1285
- part.tool === 'glob' ||
1286
- part.tool === 'grep' ||
1287
- part.tool === 'task' ||
1288
- part.tool === 'todoread' ||
1289
- part.tool === 'todowrite'
1290
- ) {
1314
+ if (part.tool === 'read') {
1315
+ const filePath = (part.state.input?.filePath as string) || ''
1316
+ const fileName = filePath.split('/').pop() || ''
1317
+ return fileName ? `*${fileName}*` : ''
1318
+ }
1319
+
1320
+ if (part.tool === 'list') {
1321
+ const path = (part.state.input?.path as string) || ''
1322
+ const dirName = path.split('/').pop() || path
1323
+ return dirName ? `*${dirName}*` : ''
1324
+ }
1325
+
1326
+ if (part.tool === 'glob') {
1327
+ const pattern = (part.state.input?.pattern as string) || ''
1328
+ return pattern ? `*${pattern}*` : ''
1329
+ }
1330
+
1331
+ if (part.tool === 'grep') {
1332
+ const pattern = (part.state.input?.pattern as string) || ''
1333
+ return pattern ? `*${pattern}*` : ''
1334
+ }
1335
+
1336
+ if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
1291
1337
  return ''
1292
1338
  }
1293
1339
 
1340
+ if (part.tool === 'task') {
1341
+ const description = (part.state.input?.description as string) || ''
1342
+ return description ? `_${description}_` : ''
1343
+ }
1344
+
1345
+ if (part.tool === 'skill') {
1346
+ const name = (part.state.input?.name as string) || ''
1347
+ return name ? `_${name}_` : ''
1348
+ }
1349
+
1294
1350
  if (!part.state.input) return ''
1295
1351
 
1296
1352
  const inputFields = Object.entries(part.state.input)
@@ -1314,19 +1370,12 @@ function formatTodoList(part: Part): string {
1314
1370
  content: string
1315
1371
  status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1316
1372
  }[]) || []
1317
- if (todos.length === 0) return ''
1318
- return todos
1319
- .map((todo, i) => {
1320
- const num = `${i + 1}.`
1321
- if (todo.status === 'in_progress') {
1322
- return `${num} **${todo.content}**`
1323
- }
1324
- if (todo.status === 'completed' || todo.status === 'cancelled') {
1325
- return `${num} ~~${todo.content}~~`
1326
- }
1327
- return `${num} ${todo.content}`
1328
- })
1329
- .join('\n')
1373
+ const activeIndex = todos.findIndex((todo) => {
1374
+ return todo.status === 'in_progress'
1375
+ })
1376
+ const activeTodo = todos[activeIndex]
1377
+ if (activeIndex === -1 || !activeTodo) return ''
1378
+ return `${activeIndex + 1}. **${activeTodo.content}**`
1330
1379
  }
1331
1380
 
1332
1381
  function formatPart(part: Part): string {
@@ -1372,17 +1421,16 @@ function formatPart(part: Part): string {
1372
1421
  toolTitle = part.state.error || 'error'
1373
1422
  } else if (part.tool === 'bash') {
1374
1423
  const command = (part.state.input?.command as string) || ''
1424
+ const description = (part.state.input?.description as string) || ''
1375
1425
  const isSingleLine = !command.includes('\n')
1376
- const hasBackticks = command.includes('`')
1377
- if (isSingleLine && command.length <= 120 && !hasBackticks) {
1426
+ const hasUnderscores = command.includes('_')
1427
+ if (isSingleLine && !hasUnderscores && command.length <= 50) {
1378
1428
  toolTitle = `_${command}_`
1379
- } else {
1380
- toolTitle = stateTitle ? `_${stateTitle}_` : ''
1429
+ } else if (description) {
1430
+ toolTitle = `_${description}_`
1431
+ } else if (stateTitle) {
1432
+ toolTitle = `_${stateTitle}_`
1381
1433
  }
1382
- } else if (part.tool === 'edit' || part.tool === 'write') {
1383
- const filePath = (part.state.input?.filePath as string) || ''
1384
- const fileName = filePath.split('/').pop() || filePath
1385
- toolTitle = fileName ? `_${fileName}_` : ''
1386
1434
  } else if (stateTitle) {
1387
1435
  toolTitle = `_${stateTitle}_`
1388
1436
  }
@@ -1541,6 +1589,51 @@ async function handleOpencodeSession({
1541
1589
  let usedModel: string | undefined
1542
1590
  let usedProviderID: string | undefined
1543
1591
  let tokensUsedInSession = 0
1592
+ let lastDisplayedContextPercentage = 0
1593
+ let modelContextLimit: number | undefined
1594
+
1595
+ let typingInterval: NodeJS.Timeout | null = null
1596
+
1597
+ function startTyping(): () => void {
1598
+ if (abortController.signal.aborted) {
1599
+ discordLogger.log(`Not starting typing, already aborted`)
1600
+ return () => {}
1601
+ }
1602
+ if (typingInterval) {
1603
+ clearInterval(typingInterval)
1604
+ typingInterval = null
1605
+ }
1606
+
1607
+ thread.sendTyping().catch((e) => {
1608
+ discordLogger.log(`Failed to send initial typing: ${e}`)
1609
+ })
1610
+
1611
+ typingInterval = setInterval(() => {
1612
+ thread.sendTyping().catch((e) => {
1613
+ discordLogger.log(`Failed to send periodic typing: ${e}`)
1614
+ })
1615
+ }, 8000)
1616
+
1617
+ if (!abortController.signal.aborted) {
1618
+ abortController.signal.addEventListener(
1619
+ 'abort',
1620
+ () => {
1621
+ if (typingInterval) {
1622
+ clearInterval(typingInterval)
1623
+ typingInterval = null
1624
+ }
1625
+ },
1626
+ { once: true },
1627
+ )
1628
+ }
1629
+
1630
+ return () => {
1631
+ if (typingInterval) {
1632
+ clearInterval(typingInterval)
1633
+ typingInterval = null
1634
+ }
1635
+ }
1636
+ }
1544
1637
 
1545
1638
  const sendPartMessage = async (part: Part) => {
1546
1639
  const content = formatPart(part) + '\n\n'
@@ -1570,58 +1663,6 @@ async function handleOpencodeSession({
1570
1663
  }
1571
1664
 
1572
1665
  const eventHandler = async () => {
1573
- // Local typing function for this session
1574
- // Outer-scoped interval for typing notifications. Only one at a time.
1575
- let typingInterval: NodeJS.Timeout | null = null
1576
-
1577
- function startTyping(thread: ThreadChannel): () => void {
1578
- if (abortController.signal.aborted) {
1579
- discordLogger.log(`Not starting typing, already aborted`)
1580
- return () => {}
1581
- }
1582
- // Clear any previous typing interval
1583
- if (typingInterval) {
1584
- clearInterval(typingInterval)
1585
- typingInterval = null
1586
- }
1587
-
1588
- // Send initial typing
1589
- thread.sendTyping().catch((e) => {
1590
- discordLogger.log(`Failed to send initial typing: ${e}`)
1591
- })
1592
-
1593
- // Set up interval to send typing every 8 seconds
1594
- typingInterval = setInterval(() => {
1595
- thread.sendTyping().catch((e) => {
1596
- discordLogger.log(`Failed to send periodic typing: ${e}`)
1597
- })
1598
- }, 8000)
1599
-
1600
- // Only add listener if not already aborted
1601
- if (!abortController.signal.aborted) {
1602
- abortController.signal.addEventListener(
1603
- 'abort',
1604
- () => {
1605
- if (typingInterval) {
1606
- clearInterval(typingInterval)
1607
- typingInterval = null
1608
- }
1609
- },
1610
- {
1611
- once: true,
1612
- },
1613
- )
1614
- }
1615
-
1616
- // Return stop function
1617
- return () => {
1618
- if (typingInterval) {
1619
- clearInterval(typingInterval)
1620
- typingInterval = null
1621
- }
1622
- }
1623
- }
1624
-
1625
1666
  try {
1626
1667
  let assistantMessageId: string | undefined
1627
1668
 
@@ -1645,6 +1686,30 @@ async function handleOpencodeSession({
1645
1686
  assistantMessageId = msg.id
1646
1687
  usedModel = msg.modelID
1647
1688
  usedProviderID = msg.providerID
1689
+
1690
+ if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
1691
+ if (!modelContextLimit) {
1692
+ try {
1693
+ const providersResponse = await getClient().provider.list({ query: { directory } })
1694
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
1695
+ const model = provider?.models?.[usedModel]
1696
+ if (model?.limit?.context) {
1697
+ modelContextLimit = model.limit.context
1698
+ }
1699
+ } catch (e) {
1700
+ sessionLogger.error('Failed to fetch provider info for context limit:', e)
1701
+ }
1702
+ }
1703
+
1704
+ if (modelContextLimit) {
1705
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
1706
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
1707
+ if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
1708
+ lastDisplayedContextPercentage = thresholdCrossed
1709
+ await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`)
1710
+ }
1711
+ }
1712
+ }
1648
1713
  }
1649
1714
  } else if (event.type === 'message.part.updated') {
1650
1715
  const part = event.properties.part
@@ -1672,7 +1737,7 @@ async function handleOpencodeSession({
1672
1737
 
1673
1738
  // Start typing on step-start
1674
1739
  if (part.type === 'step-start') {
1675
- stopTyping = startTyping(thread)
1740
+ stopTyping = startTyping()
1676
1741
  }
1677
1742
 
1678
1743
  // Send tool parts immediately when they start running
@@ -1698,7 +1763,7 @@ async function handleOpencodeSession({
1698
1763
  // start typing in a moment, so that if the session finished, because step-finish is at the end of the message, we do not show typing status
1699
1764
  setTimeout(() => {
1700
1765
  if (abortController.signal.aborted) return
1701
- stopTyping = startTyping(thread)
1766
+ stopTyping = startTyping()
1702
1767
  }, 300)
1703
1768
  }
1704
1769
  } else if (event.type === 'session.error') {
@@ -1847,13 +1912,7 @@ async function handleOpencodeSession({
1847
1912
  return
1848
1913
  }
1849
1914
 
1850
- if (originalMessage) {
1851
- try {
1852
- await originalMessage.react('⏳')
1853
- } catch (e) {
1854
- discordLogger.log(`Could not add processing reaction:`, e)
1855
- }
1856
- }
1915
+ stopTyping = startTyping()
1857
1916
 
1858
1917
  let response: { data?: unknown; error?: unknown; response: Response }
1859
1918
  if (parsedCommand?.isCommand) {
@@ -2074,14 +2133,20 @@ export async function startDiscordBot({
2074
2133
  }
2075
2134
  }
2076
2135
 
2077
- // Check if user is authoritative (server owner or has admin permissions)
2136
+ // Check if user is authoritative (server owner, admin, manage server, or has Kimaki role)
2078
2137
  if (message.guild && message.member) {
2079
2138
  const isOwner = message.member.id === message.guild.ownerId
2080
2139
  const isAdmin = message.member.permissions.has(
2081
2140
  PermissionsBitField.Flags.Administrator,
2082
2141
  )
2142
+ const canManageServer = message.member.permissions.has(
2143
+ PermissionsBitField.Flags.ManageGuild,
2144
+ )
2145
+ const hasKimakiRole = message.member.roles.cache.some(
2146
+ (role) => role.name.toLowerCase() === 'kimaki',
2147
+ )
2083
2148
 
2084
- if (!isOwner && !isAdmin) {
2149
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
2085
2150
  return
2086
2151
  }
2087
2152
  }
@@ -2297,11 +2362,8 @@ export async function startDiscordBot({
2297
2362
 
2298
2363
  // Get the channel's project directory from its topic
2299
2364
  let projectDirectory: string | undefined
2300
- if (
2301
- interaction.channel &&
2302
- interaction.channel.type === ChannelType.GuildText
2303
- ) {
2304
- const textChannel = resolveTextChannel(
2365
+ if (interaction.channel) {
2366
+ const textChannel = await resolveTextChannel(
2305
2367
  interaction.channel as TextChannel | ThreadChannel | null,
2306
2368
  )
2307
2369
  if (textChannel) {
@@ -2383,11 +2445,8 @@ export async function startDiscordBot({
2383
2445
 
2384
2446
  // Get the channel's project directory from its topic
2385
2447
  let projectDirectory: string | undefined
2386
- if (
2387
- interaction.channel &&
2388
- interaction.channel.type === ChannelType.GuildText
2389
- ) {
2390
- const textChannel = resolveTextChannel(
2448
+ if (interaction.channel) {
2449
+ const textChannel = await resolveTextChannel(
2391
2450
  interaction.channel as TextChannel | ThreadChannel | null,
2392
2451
  )
2393
2452
  if (textChannel) {
@@ -2751,7 +2810,7 @@ export async function startDiscordBot({
2751
2810
  if (partsToRender.length > 0) {
2752
2811
  const combinedContent = partsToRender
2753
2812
  .map((p) => p.content)
2754
- .join('\n\n')
2813
+ .join('\n')
2755
2814
 
2756
2815
  const discordMessage = await sendThreadMessage(
2757
2816
  thread,
@@ -2856,7 +2915,7 @@ export async function startDiscordBot({
2856
2915
  `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2857
2916
  )
2858
2917
  }
2859
- } else if (command.commandName === 'add-new-project') {
2918
+ } else if (command.commandName === 'create-new-project') {
2860
2919
  await command.deferReply({ ephemeral: false })
2861
2920
 
2862
2921
  const projectName = command.options.getString('name', true)
@@ -3091,7 +3150,7 @@ export async function startDiscordBot({
3091
3150
  return
3092
3151
  }
3093
3152
 
3094
- const textChannel = resolveTextChannel(channel as ThreadChannel)
3153
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
3095
3154
  const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3096
3155
 
3097
3156
  if (!directory) {
@@ -3162,7 +3221,7 @@ export async function startDiscordBot({
3162
3221
  return
3163
3222
  }
3164
3223
 
3165
- const textChannel = resolveTextChannel(channel as ThreadChannel)
3224
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
3166
3225
  const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3167
3226
 
3168
3227
  if (!directory) {
@@ -3270,15 +3329,20 @@ export async function startDiscordBot({
3270
3329
  const member = newState.member || oldState.member
3271
3330
  if (!member) return
3272
3331
 
3273
- // Check if user is admin or server owner
3332
+ // Check if user is admin, server owner, can manage server, or has Kimaki role
3274
3333
  const guild = newState.guild || oldState.guild
3275
3334
  const isOwner = member.id === guild.ownerId
3276
3335
  const isAdmin = member.permissions.has(
3277
3336
  PermissionsBitField.Flags.Administrator,
3278
3337
  )
3338
+ const canManageServer = member.permissions.has(
3339
+ PermissionsBitField.Flags.ManageGuild,
3340
+ )
3341
+ const hasKimakiRole = member.roles.cache.some(
3342
+ (role) => role.name.toLowerCase() === 'kimaki',
3343
+ )
3279
3344
 
3280
- if (!isOwner && !isAdmin) {
3281
- // Not an admin user, ignore
3345
+ if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
3282
3346
  return
3283
3347
  }
3284
3348
 
@@ -3304,7 +3368,9 @@ export async function startDiscordBot({
3304
3368
  if (m.id === member.id || m.user.bot) return false
3305
3369
  return (
3306
3370
  m.id === guild.ownerId ||
3307
- m.permissions.has(PermissionsBitField.Flags.Administrator)
3371
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
3372
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
3373
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
3308
3374
  )
3309
3375
  })
3310
3376
 
@@ -3349,7 +3415,9 @@ export async function startDiscordBot({
3349
3415
  if (m.id === member.id || m.user.bot) return false
3350
3416
  return (
3351
3417
  m.id === guild.ownerId ||
3352
- m.permissions.has(PermissionsBitField.Flags.Administrator)
3418
+ m.permissions.has(PermissionsBitField.Flags.Administrator) ||
3419
+ m.permissions.has(PermissionsBitField.Flags.ManageGuild) ||
3420
+ m.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki')
3353
3421
  )
3354
3422
  })
3355
3423