kimaki 0.4.21 → 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'
@@ -756,6 +757,7 @@ async function sendThreadMessage(
756
757
  ): Promise<Message> {
757
758
  const MAX_LENGTH = 2000
758
759
 
760
+ content = formatMarkdownTables(content)
759
761
  content = escapeBackticksInCodeBlocks(content)
760
762
 
761
763
  const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
@@ -1092,9 +1094,9 @@ function escapeInlineCode(text: string): string {
1092
1094
  .replace(/\|\|/g, '\\|\\|') // Double pipes (spoiler syntax)
1093
1095
  }
1094
1096
 
1095
- function resolveTextChannel(
1097
+ async function resolveTextChannel(
1096
1098
  channel: TextChannel | ThreadChannel | null | undefined,
1097
- ): TextChannel | null {
1099
+ ): Promise<TextChannel | null> {
1098
1100
  if (!channel) {
1099
1101
  return null
1100
1102
  }
@@ -1108,9 +1110,12 @@ function resolveTextChannel(
1108
1110
  channel.type === ChannelType.PrivateThread ||
1109
1111
  channel.type === ChannelType.AnnouncementThread
1110
1112
  ) {
1111
- const parent = channel.parent
1112
- if (parent?.type === ChannelType.GuildText) {
1113
- 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
+ }
1114
1119
  }
1115
1120
  }
1116
1121
 
@@ -1328,10 +1333,20 @@ function getToolSummaryText(part: Part): string {
1328
1333
  return pattern ? `*${pattern}*` : ''
1329
1334
  }
1330
1335
 
1331
- if (part.tool === 'bash' || part.tool === 'task' || part.tool === 'todoread' || part.tool === 'todowrite') {
1336
+ if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
1332
1337
  return ''
1333
1338
  }
1334
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
+
1335
1350
  if (!part.state.input) return ''
1336
1351
 
1337
1352
  const inputFields = Object.entries(part.state.input)
@@ -1355,19 +1370,12 @@ function formatTodoList(part: Part): string {
1355
1370
  content: string
1356
1371
  status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
1357
1372
  }[]) || []
1358
- if (todos.length === 0) return ''
1359
- return todos
1360
- .map((todo, i) => {
1361
- const num = `${i + 1}.`
1362
- if (todo.status === 'in_progress') {
1363
- return `${num} **${todo.content}**`
1364
- }
1365
- if (todo.status === 'completed' || todo.status === 'cancelled') {
1366
- return `${num} ~~${todo.content}~~`
1367
- }
1368
- return `${num} ${todo.content}`
1369
- })
1370
- .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}**`
1371
1379
  }
1372
1380
 
1373
1381
  function formatPart(part: Part): string {
@@ -1415,9 +1423,9 @@ function formatPart(part: Part): string {
1415
1423
  const command = (part.state.input?.command as string) || ''
1416
1424
  const description = (part.state.input?.description as string) || ''
1417
1425
  const isSingleLine = !command.includes('\n')
1418
- const hasBackticks = command.includes('`')
1419
- if (isSingleLine && !hasBackticks && command.length <= 50) {
1420
- toolTitle = `\`${command}\``
1426
+ const hasUnderscores = command.includes('_')
1427
+ if (isSingleLine && !hasUnderscores && command.length <= 50) {
1428
+ toolTitle = `_${command}_`
1421
1429
  } else if (description) {
1422
1430
  toolTitle = `_${description}_`
1423
1431
  } else if (stateTitle) {
@@ -1581,6 +1589,8 @@ async function handleOpencodeSession({
1581
1589
  let usedModel: string | undefined
1582
1590
  let usedProviderID: string | undefined
1583
1591
  let tokensUsedInSession = 0
1592
+ let lastDisplayedContextPercentage = 0
1593
+ let modelContextLimit: number | undefined
1584
1594
 
1585
1595
  let typingInterval: NodeJS.Timeout | null = null
1586
1596
 
@@ -1676,6 +1686,30 @@ async function handleOpencodeSession({
1676
1686
  assistantMessageId = msg.id
1677
1687
  usedModel = msg.modelID
1678
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
+ }
1679
1713
  }
1680
1714
  } else if (event.type === 'message.part.updated') {
1681
1715
  const part = event.properties.part
@@ -2328,11 +2362,8 @@ export async function startDiscordBot({
2328
2362
 
2329
2363
  // Get the channel's project directory from its topic
2330
2364
  let projectDirectory: string | undefined
2331
- if (
2332
- interaction.channel &&
2333
- interaction.channel.type === ChannelType.GuildText
2334
- ) {
2335
- const textChannel = resolveTextChannel(
2365
+ if (interaction.channel) {
2366
+ const textChannel = await resolveTextChannel(
2336
2367
  interaction.channel as TextChannel | ThreadChannel | null,
2337
2368
  )
2338
2369
  if (textChannel) {
@@ -2414,11 +2445,8 @@ export async function startDiscordBot({
2414
2445
 
2415
2446
  // Get the channel's project directory from its topic
2416
2447
  let projectDirectory: string | undefined
2417
- if (
2418
- interaction.channel &&
2419
- interaction.channel.type === ChannelType.GuildText
2420
- ) {
2421
- const textChannel = resolveTextChannel(
2448
+ if (interaction.channel) {
2449
+ const textChannel = await resolveTextChannel(
2422
2450
  interaction.channel as TextChannel | ThreadChannel | null,
2423
2451
  )
2424
2452
  if (textChannel) {
@@ -2782,7 +2810,7 @@ export async function startDiscordBot({
2782
2810
  if (partsToRender.length > 0) {
2783
2811
  const combinedContent = partsToRender
2784
2812
  .map((p) => p.content)
2785
- .join('\n\n')
2813
+ .join('\n')
2786
2814
 
2787
2815
  const discordMessage = await sendThreadMessage(
2788
2816
  thread,
@@ -2887,7 +2915,7 @@ export async function startDiscordBot({
2887
2915
  `Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
2888
2916
  )
2889
2917
  }
2890
- } else if (command.commandName === 'add-new-project') {
2918
+ } else if (command.commandName === 'create-new-project') {
2891
2919
  await command.deferReply({ ephemeral: false })
2892
2920
 
2893
2921
  const projectName = command.options.getString('name', true)
@@ -3122,7 +3150,7 @@ export async function startDiscordBot({
3122
3150
  return
3123
3151
  }
3124
3152
 
3125
- const textChannel = resolveTextChannel(channel as ThreadChannel)
3153
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
3126
3154
  const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3127
3155
 
3128
3156
  if (!directory) {
@@ -3193,7 +3221,7 @@ export async function startDiscordBot({
3193
3221
  return
3194
3222
  }
3195
3223
 
3196
- const textChannel = resolveTextChannel(channel as ThreadChannel)
3224
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
3197
3225
  const { projectDirectory: directory } = getKimakiMetadata(textChannel)
3198
3226
 
3199
3227
  if (!directory) {
@@ -0,0 +1,440 @@
1
+ import { test, expect } from 'vitest'
2
+ import { formatMarkdownTables } from './format-tables.js'
3
+
4
+ test('formats simple table', () => {
5
+ const input = `| Name | Age |
6
+ | --- | --- |
7
+ | Alice | 30 |
8
+ | Bob | 25 |`
9
+ const result = formatMarkdownTables(input)
10
+ expect(result).toMatchInlineSnapshot(`
11
+ "\`\`\`
12
+ Name Age
13
+ ----- ---
14
+ Alice 30
15
+ Bob 25
16
+ \`\`\`
17
+ "
18
+ `)
19
+ })
20
+
21
+ test('formats table with varying column widths', () => {
22
+ const input = `| Item | Quantity | Price |
23
+ | --- | --- | --- |
24
+ | Apples | 10 | $5 |
25
+ | Oranges | 3 | $2 |
26
+ | Bananas with long name | 100 | $15.99 |`
27
+ const result = formatMarkdownTables(input)
28
+ expect(result).toMatchInlineSnapshot(`
29
+ "\`\`\`
30
+ Item Quantity Price
31
+ ---------------------- -------- ------
32
+ Apples 10 $5
33
+ Oranges 3 $2
34
+ Bananas with long name 100 $15.99
35
+ \`\`\`
36
+ "
37
+ `)
38
+ })
39
+
40
+ test('strips bold formatting from cells', () => {
41
+ const input = `| Header | Value |
42
+ | --- | --- |
43
+ | **Bold text** | Normal |
44
+ | Mixed **bold** text | Another |`
45
+ const result = formatMarkdownTables(input)
46
+ expect(result).toMatchInlineSnapshot(`
47
+ "\`\`\`
48
+ Header Value
49
+ --------------- -------
50
+ Bold text Normal
51
+ Mixed bold text Another
52
+ \`\`\`
53
+ "
54
+ `)
55
+ })
56
+
57
+ test('strips italic formatting from cells', () => {
58
+ const input = `| Header | Value |
59
+ | --- | --- |
60
+ | *Italic text* | Normal |
61
+ | _Also italic_ | Another |`
62
+ const result = formatMarkdownTables(input)
63
+ expect(result).toMatchInlineSnapshot(`
64
+ "\`\`\`
65
+ Header Value
66
+ ----------- -------
67
+ Italic text Normal
68
+ Also italic Another
69
+ \`\`\`
70
+ "
71
+ `)
72
+ })
73
+
74
+ test('extracts URL from links', () => {
75
+ const input = `| Name | Link |
76
+ | --- | --- |
77
+ | Google | [Click here](https://google.com) |
78
+ | GitHub | [GitHub Home](https://github.com) |`
79
+ const result = formatMarkdownTables(input)
80
+ expect(result).toMatchInlineSnapshot(`
81
+ "\`\`\`
82
+ Name Link
83
+ ------ ------------------
84
+ Google https://google.com
85
+ GitHub https://github.com
86
+ \`\`\`
87
+ "
88
+ `)
89
+ })
90
+
91
+ test('handles inline code in cells', () => {
92
+ const input = `| Function | Description |
93
+ | --- | --- |
94
+ | \`console.log\` | Logs to console |
95
+ | \`Array.map\` | Maps array items |`
96
+ const result = formatMarkdownTables(input)
97
+ expect(result).toMatchInlineSnapshot(`
98
+ "\`\`\`
99
+ Function Description
100
+ ----------- ----------------
101
+ console.log Logs to console
102
+ Array.map Maps array items
103
+ \`\`\`
104
+ "
105
+ `)
106
+ })
107
+
108
+ test('handles mixed formatting in single cell', () => {
109
+ const input = `| Description |
110
+ | --- |
111
+ | This has **bold**, *italic*, and \`code\` |
112
+ | Also [a link](https://example.com) here |`
113
+ const result = formatMarkdownTables(input)
114
+ expect(result).toMatchInlineSnapshot(`
115
+ "\`\`\`
116
+ Description
117
+ -------------------------------
118
+ This has bold, italic, and code
119
+ Also https://example.com here
120
+ \`\`\`
121
+ "
122
+ `)
123
+ })
124
+
125
+ test('handles strikethrough text', () => {
126
+ const input = `| Status | Item |
127
+ | --- | --- |
128
+ | Done | ~~Deleted item~~ |
129
+ | Active | Normal item |`
130
+ const result = formatMarkdownTables(input)
131
+ expect(result).toMatchInlineSnapshot(`
132
+ "\`\`\`
133
+ Status Item
134
+ ------ ------------
135
+ Done Deleted item
136
+ Active Normal item
137
+ \`\`\`
138
+ "
139
+ `)
140
+ })
141
+
142
+ test('preserves content before table', () => {
143
+ const input = `Here is some text before the table.
144
+
145
+ | Col A | Col B |
146
+ | --- | --- |
147
+ | 1 | 2 |`
148
+ const result = formatMarkdownTables(input)
149
+ expect(result).toMatchInlineSnapshot(`
150
+ "Here is some text before the table.
151
+
152
+ \`\`\`
153
+ Col A Col B
154
+ ----- -----
155
+ 1 2
156
+ \`\`\`
157
+ "
158
+ `)
159
+ })
160
+
161
+ test('preserves content after table', () => {
162
+ const input = `| Col A | Col B |
163
+ | --- | --- |
164
+ | 1 | 2 |
165
+
166
+ And here is text after.`
167
+ const result = formatMarkdownTables(input)
168
+ expect(result).toMatchInlineSnapshot(`
169
+ "\`\`\`
170
+ Col A Col B
171
+ ----- -----
172
+ 1 2
173
+ \`\`\`
174
+ And here is text after."
175
+ `)
176
+ })
177
+
178
+ test('preserves content before and after table', () => {
179
+ const input = `Some intro text.
180
+
181
+ | Name | Value |
182
+ | --- | --- |
183
+ | Key | 123 |
184
+
185
+ Some outro text.`
186
+ const result = formatMarkdownTables(input)
187
+ expect(result).toMatchInlineSnapshot(`
188
+ "Some intro text.
189
+
190
+ \`\`\`
191
+ Name Value
192
+ ---- -----
193
+ Key 123
194
+ \`\`\`
195
+ Some outro text."
196
+ `)
197
+ })
198
+
199
+ test('handles multiple tables in same content', () => {
200
+ const input = `First table:
201
+
202
+ | A | B |
203
+ | --- | --- |
204
+ | 1 | 2 |
205
+
206
+ Some text between.
207
+
208
+ Second table:
209
+
210
+ | X | Y | Z |
211
+ | --- | --- | --- |
212
+ | a | b | c |`
213
+ const result = formatMarkdownTables(input)
214
+ expect(result).toMatchInlineSnapshot(`
215
+ "First table:
216
+
217
+ \`\`\`
218
+ A B
219
+ - -
220
+ 1 2
221
+ \`\`\`
222
+ Some text between.
223
+
224
+ Second table:
225
+
226
+ \`\`\`
227
+ X Y Z
228
+ - - -
229
+ a b c
230
+ \`\`\`
231
+ "
232
+ `)
233
+ })
234
+
235
+ test('handles empty cells', () => {
236
+ const input = `| Name | Optional |
237
+ | --- | --- |
238
+ | Alice | |
239
+ | | Bob |
240
+ | | |`
241
+ const result = formatMarkdownTables(input)
242
+ expect(result).toMatchInlineSnapshot(`
243
+ "\`\`\`
244
+ Name Optional
245
+ ----- --------
246
+ Alice
247
+ Bob
248
+
249
+ \`\`\`
250
+ "
251
+ `)
252
+ })
253
+
254
+ test('handles single column table', () => {
255
+ const input = `| Items |
256
+ | --- |
257
+ | Apple |
258
+ | Banana |
259
+ | Cherry |`
260
+ const result = formatMarkdownTables(input)
261
+ expect(result).toMatchInlineSnapshot(`
262
+ "\`\`\`
263
+ Items
264
+ ------
265
+ Apple
266
+ Banana
267
+ Cherry
268
+ \`\`\`
269
+ "
270
+ `)
271
+ })
272
+
273
+ test('handles single row table', () => {
274
+ const input = `| A | B | C | D |
275
+ | --- | --- | --- | --- |
276
+ | 1 | 2 | 3 | 4 |`
277
+ const result = formatMarkdownTables(input)
278
+ expect(result).toMatchInlineSnapshot(`
279
+ "\`\`\`
280
+ A B C D
281
+ - - - -
282
+ 1 2 3 4
283
+ \`\`\`
284
+ "
285
+ `)
286
+ })
287
+
288
+ test('handles nested formatting', () => {
289
+ const input = `| Description |
290
+ | --- |
291
+ | **Bold with *nested italic* inside** |
292
+ | *Italic with **nested bold** inside* |`
293
+ const result = formatMarkdownTables(input)
294
+ expect(result).toMatchInlineSnapshot(`
295
+ "\`\`\`
296
+ Description
297
+ ------------------------------
298
+ Bold with nested italic inside
299
+ Italic with nested bold inside
300
+ \`\`\`
301
+ "
302
+ `)
303
+ })
304
+
305
+ test('handles image references', () => {
306
+ const input = `| Icon | Name |
307
+ | --- | --- |
308
+ | ![alt](https://example.com/icon.png) | Item 1 |
309
+ | ![](https://cdn.test.com/img.jpg) | Item 2 |`
310
+ const result = formatMarkdownTables(input)
311
+ expect(result).toMatchInlineSnapshot(`
312
+ "\`\`\`
313
+ Icon Name
314
+ ---------------------------- ------
315
+ https://example.com/icon.png Item 1
316
+ https://cdn.test.com/img.jpg Item 2
317
+ \`\`\`
318
+ "
319
+ `)
320
+ })
321
+
322
+ test('preserves code blocks alongside tables', () => {
323
+ const input = `Some code:
324
+
325
+ \`\`\`js
326
+ const x = 1
327
+ \`\`\`
328
+
329
+ A table:
330
+
331
+ | Key | Value |
332
+ | --- | --- |
333
+ | a | 1 |
334
+
335
+ More code:
336
+
337
+ \`\`\`python
338
+ print("hello")
339
+ \`\`\``
340
+ const result = formatMarkdownTables(input)
341
+ expect(result).toMatchInlineSnapshot(`
342
+ "Some code:
343
+
344
+ \`\`\`js
345
+ const x = 1
346
+ \`\`\`
347
+
348
+ A table:
349
+
350
+ \`\`\`
351
+ Key Value
352
+ --- -----
353
+ a 1
354
+ \`\`\`
355
+ More code:
356
+
357
+ \`\`\`python
358
+ print("hello")
359
+ \`\`\`"
360
+ `)
361
+ })
362
+
363
+ test('handles content without tables', () => {
364
+ const input = `Just some regular markdown.
365
+
366
+ - List item 1
367
+ - List item 2
368
+
369
+ **Bold text** and *italic*.`
370
+ const result = formatMarkdownTables(input)
371
+ expect(result).toMatchInlineSnapshot(`
372
+ "Just some regular markdown.
373
+
374
+ - List item 1
375
+ - List item 2
376
+
377
+ **Bold text** and *italic*."
378
+ `)
379
+ })
380
+
381
+ test('handles complex real-world table', () => {
382
+ const input = `## API Endpoints
383
+
384
+ | Method | Endpoint | Description | Auth |
385
+ | --- | --- | --- | --- |
386
+ | GET | \`/api/users\` | List all users | [Bearer token](https://docs.example.com/auth) |
387
+ | POST | \`/api/users\` | Create **new** user | Required |
388
+ | DELETE | \`/api/users/:id\` | ~~Remove~~ *Deactivate* user | Admin only |`
389
+ const result = formatMarkdownTables(input)
390
+ expect(result).toMatchInlineSnapshot(`
391
+ "## API Endpoints
392
+
393
+ \`\`\`
394
+ Method Endpoint Description Auth
395
+ ------ -------------- ---------------------- -----------------------------
396
+ GET /api/users List all users https://docs.example.com/auth
397
+ POST /api/users Create new user Required
398
+ DELETE /api/users/:id Remove Deactivate user Admin only
399
+ \`\`\`
400
+ "
401
+ `)
402
+ })
403
+
404
+ test('handles unicode content', () => {
405
+ const input = `| Emoji | Name | Country |
406
+ | --- | --- | --- |
407
+ | 🍎 | Apple | 日本 |
408
+ | 🍊 | Orange | España |
409
+ | 🍌 | Banana | Ελλάδα |`
410
+ const result = formatMarkdownTables(input)
411
+ expect(result).toMatchInlineSnapshot(`
412
+ "\`\`\`
413
+ Emoji Name Country
414
+ ----- ------ -------
415
+ 🍎 Apple 日本
416
+ 🍊 Orange España
417
+ 🍌 Banana Ελλάδα
418
+ \`\`\`
419
+ "
420
+ `)
421
+ })
422
+
423
+ test('handles numbers and special characters', () => {
424
+ const input = `| Price | Discount | Final |
425
+ | --- | --- | --- |
426
+ | $100.00 | -15% | $85.00 |
427
+ | €50,00 | -10% | €45,00 |
428
+ | £75.99 | N/A | £75.99 |`
429
+ const result = formatMarkdownTables(input)
430
+ expect(result).toMatchInlineSnapshot(`
431
+ "\`\`\`
432
+ Price Discount Final
433
+ ------- -------- ------
434
+ $100.00 -15% $85.00
435
+ €50,00 -10% €45,00
436
+ £75.99 N/A £75.99
437
+ \`\`\`
438
+ "
439
+ `)
440
+ })